From dc67ef4d6f44f6ae72bfe0964d3106cd23dc1143 Mon Sep 17 00:00:00 2001 From: Volodymyr Chekyrta Date: Mon, 12 Feb 2024 19:11:22 +0200 Subject: [PATCH 01/39] feat: Firebase config --- app/build.gradle | 27 ++++++----- .../main/java/org/openedx/app/OpenEdXApp.kt | 11 +---- .../org/edx/builder/ConfigHelper.groovy | 45 +++++++++++++++++++ .../org/openedx/core/config/FirebaseConfig.kt | 6 +-- default_config/dev/config.yaml | 7 +++ default_config/prod/config.yaml | 7 +++ default_config/stage/config.yaml | 7 +++ 7 files changed, 88 insertions(+), 22 deletions(-) diff --git a/app/build.gradle b/app/build.gradle index a0f268eb6..d8aa5db2b 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -1,14 +1,19 @@ -plugins { - id 'com.android.application' - id 'org.jetbrains.kotlin.android' - id 'kotlin-parcelize' - id 'kotlin-kapt' - 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() +def firebaseConfig = config.get('FIREBASE') +def firebaseEnabled = firebaseConfig?.getOrDefault('ENABLED', false) + +apply plugin: 'com.android.application' +apply plugin: 'org.jetbrains.kotlin.android' +apply plugin: 'kotlin-parcelize' +apply plugin: 'kotlin-kapt' +if (firebaseEnabled) { + apply plugin: 'com.google.gms.google-services' + apply plugin: 'com.google.firebase.crashlytics' + + configHelper.generateGoogleServicesJson(appId) +} android { compileSdk 34 @@ -57,8 +62,10 @@ android { minifyEnabled true proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro' - firebaseCrashlytics { - mappingFileUploadEnabled false + if (firebaseEnabled) { + firebaseCrashlytics { + mappingFileUploadEnabled false + } } } } diff --git a/app/src/main/java/org/openedx/app/OpenEdXApp.kt b/app/src/main/java/org/openedx/app/OpenEdXApp.kt index 9f1f95977..865290940 100644 --- a/app/src/main/java/org/openedx/app/OpenEdXApp.kt +++ b/app/src/main/java/org/openedx/app/OpenEdXApp.kt @@ -26,15 +26,8 @@ class OpenEdXApp : Application() { screenModule ) } - val firebaseConfig = config.getFirebaseConfig() - if (firebaseConfig.enabled) { - val options = FirebaseOptions.Builder() - .setProjectId(firebaseConfig.projectId) - .setApplicationId(firebaseConfig.applicationId) - .setApiKey(firebaseConfig.apiKey) - .setGcmSenderId(firebaseConfig.gcmSenderId) - .build() - Firebase.initialize(this, options) + if (config.getFirebaseConfig().enabled) { + Firebase.initialize(this) } } diff --git a/buildSrc/src/main/groovy/org/edx/builder/ConfigHelper.groovy b/buildSrc/src/main/groovy/org/edx/builder/ConfigHelper.groovy index ed78319bc..32f1fb678 100644 --- a/buildSrc/src/main/groovy/org/edx/builder/ConfigHelper.groovy +++ b/buildSrc/src/main/groovy/org/edx/builder/ConfigHelper.groovy @@ -95,4 +95,49 @@ class ConfigHelper { it.write(new JsonBuilder(configJson).toPrettyString()) } } + + def generateGoogleServicesJson(applicationId) { + def config = fetchConfig() + def firebase = config.get("FIREBASE") + if (!firebase) { + return + } + if (!firebase.getOrDefault("ENABLED", false)) { + return + } + + def googleServicesJsonPath = projectDir.path + "/app/" + new File(googleServicesJsonPath).mkdirs() + + def projectInfo = [ + project_number: firebase.getOrDefault("PROJECT_NUMBER", ""), + project_id : firebase.getOrDefault("PROJECT_ID", ""), + storage_bucket: "${firebase.getOrDefault("PROJECT_ID", "")}.appspot.com" + ] + def clientInfo = [ + mobilesdk_app_id : firebase.getOrDefault("APPLICATION_ID", ""), + android_client_info: [ + package_name: applicationId + ] + ] + def client = [ + client_info : clientInfo, + oauth_client: [], + api_key : [[current_key: firebase.getOrDefault("API_KEY", "")]], + services : [ + appinvite_service: [ + other_platform_oauth_client: [] + ] + ] + ] + def configJson = [ + project_info : projectInfo, + client : [client], + configuration_version: "1" + ] + + new FileWriter(googleServicesJsonPath + "/google-services.json").withWriter { + it.write(new JsonBuilder(configJson).toPrettyString()) + } + } } diff --git a/core/src/main/java/org/openedx/core/config/FirebaseConfig.kt b/core/src/main/java/org/openedx/core/config/FirebaseConfig.kt index b003c3230..db8010d70 100644 --- a/core/src/main/java/org/openedx/core/config/FirebaseConfig.kt +++ b/core/src/main/java/org/openedx/core/config/FirebaseConfig.kt @@ -6,6 +6,9 @@ data class FirebaseConfig( @SerializedName("ENABLED") val enabled: Boolean = false, + @SerializedName("PROJECT_NUMBER") + val gcmSenderId: String = "", + @SerializedName("PROJECT_ID") val projectId: String = "", @@ -14,7 +17,4 @@ data class FirebaseConfig( @SerializedName("API_KEY") val apiKey: String = "", - - @SerializedName("GCM_SENDER_ID") - val gcmSenderId: String = "", ) diff --git a/default_config/dev/config.yaml b/default_config/dev/config.yaml index f074ddd9d..4b9ae4b61 100644 --- a/default_config/dev/config.yaml +++ b/default_config/dev/config.yaml @@ -28,6 +28,13 @@ PROGRAM: PROGRAM_URL: '' PROGRAM_DETAIL_URL_TEMPLATE: '' +FIREBASE: + ENABLED: false + PROJECT_NUMBER: '' + PROJECT_ID: '' + APPLICATION_ID: '' + API_KEY: '' + GOOGLE: ENABLED: false CLIENT_ID: '' diff --git a/default_config/prod/config.yaml b/default_config/prod/config.yaml index 25df957a0..4647d16a8 100644 --- a/default_config/prod/config.yaml +++ b/default_config/prod/config.yaml @@ -28,6 +28,13 @@ PROGRAM: PROGRAM_URL: '' PROGRAM_DETAIL_URL_TEMPLATE: '' +FIREBASE: + ENABLED: false + PROJECT_NUMBER: '' + PROJECT_ID: '' + APPLICATION_ID: '' + API_KEY: '' + GOOGLE: ENABLED: false CLIENT_ID: '' diff --git a/default_config/stage/config.yaml b/default_config/stage/config.yaml index 25df957a0..4647d16a8 100644 --- a/default_config/stage/config.yaml +++ b/default_config/stage/config.yaml @@ -28,6 +28,13 @@ PROGRAM: PROGRAM_URL: '' PROGRAM_DETAIL_URL_TEMPLATE: '' +FIREBASE: + ENABLED: false + PROJECT_NUMBER: '' + PROJECT_ID: '' + APPLICATION_ID: '' + API_KEY: '' + GOOGLE: ENABLED: false CLIENT_ID: '' From 5f13afd42215e5031134a27e301f03f084b0b362 Mon Sep 17 00:00:00 2001 From: Volodymyr Chekyrta Date: Tue, 13 Feb 2024 11:43:46 +0200 Subject: [PATCH 02/39] feat: Firebase config Added preBuild generateGoogleServicesJson task to ensure that the file is generated after a build variant is changed --- app/build.gradle | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/app/build.gradle b/app/build.gradle index d8aa5db2b..1a3560f7f 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -12,7 +12,11 @@ if (firebaseEnabled) { apply plugin: 'com.google.gms.google-services' apply plugin: 'com.google.firebase.crashlytics' - configHelper.generateGoogleServicesJson(appId) + tasks.register('generateGoogleServicesJson') { + configHelper.generateGoogleServicesJson(appId) + } + + preBuild.dependsOn(generateGoogleServicesJson) } android { From 43a5b9cb14246cec1e7fd51de9007fbc4f14eeb9 Mon Sep 17 00:00:00 2001 From: Farhan Arshad Date: Fri, 26 Jan 2024 10:53:25 +0500 Subject: [PATCH 03/39] feat: WebView discovery capabilities for Pre-Login Experience MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Integrate search to align with configured discovery settings. - “Explore All Courses" redirects based on the feature discovery flag. - Auth Panel (SignIn, Register) remains visible during pre-login webview discovery. - Ensure Sign In & Enroll mirror Native Discovery & Market App functionality. - Clear edit text after submit Logistration. - Fix test cases for View Models. LEARNER-9798 --- .../main/java/org/openedx/app/AppRouter.kt | 23 +++-- .../main/java/org/openedx/app/MainFragment.kt | 23 +++-- .../java/org/openedx/app/di/ScreenModule.kt | 40 ++++++-- .../openedx/auth/presentation/AuthRouter.kt | 12 ++- .../logistration/LogistrationFragment.kt | 20 +++- .../presentation/signin/SignInFragment.kt | 25 +++-- .../presentation/signin/SignInViewModel.kt | 1 + .../presentation/signup/SignUpFragment.kt | 17 +++- .../presentation/signup/SignUpViewModel.kt | 1 + .../signin/SignInViewModelTest.kt | 8 ++ .../signup/SignUpViewModelTest.kt | 7 ++ .../java/org/openedx/core/ui/ComposeCommon.kt | 7 +- .../java/org/openedx/core/utils/UrlUtils.kt | 41 ++++++++ .../course/presentation/CourseRouter.kt | 4 +- .../detail/CourseDetailsFragment.kt | 4 +- .../presentation/info/CourseInfoFragment.kt | 93 ++++++++++++++----- .../presentation/info/CourseInfoUIState.kt | 9 ++ .../presentation/info/CourseInfoViewModel.kt | 54 +++++++++-- .../presentation/DiscoveryNavigator.kt | 4 +- .../discovery/presentation/DiscoveryRouter.kt | 4 +- .../presentation/NativeDiscoveryFragment.kt | 6 +- .../presentation/WebViewDiscoveryFragment.kt | 63 ++++++++++++- .../presentation/WebViewDiscoveryViewModel.kt | 26 +++++- .../search/CourseSearchFragment.kt | 4 +- .../org/openedx/whatsnew/WhatsNewRouter.kt | 2 +- .../presentation/whatsnew/WhatsNewFragment.kt | 18 +++- .../whatsnew/WhatsNewViewModel.kt | 1 + .../openedx/whatsnew/WhatsNewViewModelTest.kt | 2 +- 28 files changed, 420 insertions(+), 99 deletions(-) create mode 100644 course/src/main/java/org/openedx/course/presentation/info/CourseInfoUIState.kt diff --git a/app/src/main/java/org/openedx/app/AppRouter.kt b/app/src/main/java/org/openedx/app/AppRouter.kt index 15f7be5a0..eff44c9a7 100644 --- a/app/src/main/java/org/openedx/app/AppRouter.kt +++ b/app/src/main/java/org/openedx/app/AppRouter.kt @@ -28,6 +28,7 @@ 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.WebViewDiscoveryFragment import org.openedx.discovery.presentation.search.CourseSearchFragment import org.openedx.discussion.domain.model.DiscussionComment import org.openedx.discussion.domain.model.Thread @@ -52,19 +53,19 @@ class AppRouter : AuthRouter, DiscoveryRouter, DashboardRouter, CourseRouter, Di ProfileRouter, AppUpgradeRouter, WhatsNewRouter { //region AuthRouter - override fun navigateToMain(fm: FragmentManager, courseId: String?) { + override fun navigateToMain(fm: FragmentManager, courseId: String?, infoType: String?) { fm.popBackStack() fm.beginTransaction() - .replace(R.id.container, MainFragment.newInstance(courseId)) + .replace(R.id.container, MainFragment.newInstance(courseId, infoType)) .commit() } - override fun navigateToSignIn(fm: FragmentManager, courseId: String?) { - replaceFragmentWithBackStack(fm, SignInFragment.newInstance(courseId)) + override fun navigateToSignIn(fm: FragmentManager, courseId: String?, infoType: String?) { + replaceFragmentWithBackStack(fm, SignInFragment.newInstance(courseId, infoType)) } - override fun navigateToSignUp(fm: FragmentManager, courseId: String?) { - replaceFragmentWithBackStack(fm, SignUpFragment.newInstance(courseId)) + override fun navigateToSignUp(fm: FragmentManager, courseId: String?, infoType: String?) { + replaceFragmentWithBackStack(fm, SignUpFragment.newInstance(courseId, infoType)) } override fun navigateToLogistration(fm: FragmentManager, courseId: String?) { @@ -75,14 +76,18 @@ class AppRouter : AuthRouter, DiscoveryRouter, DashboardRouter, CourseRouter, Di replaceFragmentWithBackStack(fm, RestorePasswordFragment()) } - override fun navigateToDiscoverCourses(fm: FragmentManager, querySearch: String) { + override fun navigateToNativeDiscoverCourses(fm: FragmentManager, querySearch: String) { replaceFragmentWithBackStack(fm, NativeDiscoveryFragment.newInstance(querySearch)) } - override fun navigateToWhatsNew(fm: FragmentManager, courseId: String?) { + override fun navigateToWebDiscoverCourses(fm: FragmentManager, querySearch: String) { + replaceFragmentWithBackStack(fm, WebViewDiscoveryFragment.newInstance(querySearch)) + } + + override fun navigateToWhatsNew(fm: FragmentManager, courseId: String?, infoType: String?) { fm.popBackStack() fm.beginTransaction() - .replace(R.id.container, WhatsNewFragment.newInstance(courseId)) + .replace(R.id.container, WhatsNewFragment.newInstance(courseId, infoType)) .commit() } diff --git a/app/src/main/java/org/openedx/app/MainFragment.kt b/app/src/main/java/org/openedx/app/MainFragment.kt index 2021e038f..8534eaffe 100644 --- a/app/src/main/java/org/openedx/app/MainFragment.kt +++ b/app/src/main/java/org/openedx/app/MainFragment.kt @@ -13,6 +13,7 @@ import org.koin.android.ext.android.inject import org.koin.androidx.viewmodel.ext.android.viewModel import org.openedx.app.adapter.MainNavigationFragmentAdapter import org.openedx.app.databinding.FragmentMainBinding +import org.openedx.core.config.Config import org.openedx.core.presentation.global.app_upgrade.UpgradeRequiredFragment import org.openedx.core.presentation.global.viewBinding import org.openedx.dashboard.presentation.dashboard.DashboardFragment @@ -27,6 +28,7 @@ class MainFragment : Fragment(R.layout.fragment_main) { private val analytics by inject() private val viewModel by viewModel() private val router by inject() + private val config by inject() private lateinit var adapter: MainNavigationFragmentAdapter @@ -82,12 +84,19 @@ class MainFragment : Fragment(R.layout.fragment_main) { } requireArguments().apply { - this.getString(ARG_COURSE_ID, null)?.let { - if (it.isNotBlank()) { - router.navigateToCourseDetail(parentFragmentManager, it) + getString(ARG_COURSE_ID).takeIf { it.isNullOrBlank().not() }?.let { courseId -> + val infoType = getString(ARG_INFO_TYPE) + + if (config.getDiscoveryConfig().isViewTypeWebView() && infoType != null) { + router.navigateToCourseInfo(parentFragmentManager, courseId, infoType) + } else { + router.navigateToCourseDetail(parentFragmentManager, courseId) } + + // Clear arguments after navigation + putString(ARG_COURSE_ID, "") + putString(ARG_INFO_TYPE, "") } - this.putString(ARG_COURSE_ID, null) } } @@ -121,10 +130,12 @@ class MainFragment : Fragment(R.layout.fragment_main) { companion object { private const val ARG_COURSE_ID = "courseId" - fun newInstance(courseId: String? = null): MainFragment { + private const val ARG_INFO_TYPE = "info_type" + fun newInstance(courseId: String? = null, infoType: String? = null): MainFragment { val fragment = MainFragment() fragment.arguments = bundleOf( - ARG_COURSE_ID to courseId + ARG_COURSE_ID to courseId, + ARG_INFO_TYPE to infoType ) return fragment } 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 9ccac7be7..2f86dbb5a 100644 --- a/app/src/main/java/org/openedx/app/di/ScreenModule.kt +++ b/app/src/main/java/org/openedx/app/di/ScreenModule.kt @@ -64,7 +64,7 @@ val screenModule = module { factory { AuthRepository(get(), get(), get()) } factory { AuthInteractor(get()) } factory { Validator() } - viewModel { (courseId: String?) -> + viewModel { (courseId: String?, infoType: String?) -> SignInViewModel( get(), get(), @@ -75,10 +75,12 @@ val screenModule = module { get(), get(), courseId, + infoType, ) } - viewModel { (courseId: String?) -> - SignUpViewModel(get(), get(), get(), get(), get(), get(), get(), courseId) + + viewModel { (courseId: String?, infoType: String?) -> + SignUpViewModel(get(), get(), get(), get(), get(), get(), get(), courseId, infoType) } viewModel { RestorePasswordViewModel(get(), get(), get(), get()) } @@ -89,7 +91,15 @@ val screenModule = module { factory { DiscoveryRepository(get(), get()) } factory { DiscoveryInteractor(get()) } viewModel { NativeDiscoveryViewModel(get(), get(), get(), get(), get(), get(), get()) } - viewModel { WebViewDiscoveryViewModel(get(), get(), get()) } + viewModel { (querySearch: String) -> + WebViewDiscoveryViewModel( + get(), + get(), + get(), + get(), + querySearch + ) + } factory { ProfileRepository(get(), get(), get(), get(), get()) } factory { ProfileInteractor(get()) } @@ -116,7 +126,19 @@ val screenModule = module { single { CourseRepository(get(), get(), get(), get()) } factory { CourseInteractor(get()) } - viewModel { CourseInfoViewModel(get(), get(), get(), get(), get(), get()) } + viewModel { (pathId: String, infoType: String) -> + CourseInfoViewModel( + get(), + get(), + get(), + get(), + get(), + get(), + get(), + pathId, + infoType + ) + } viewModel { (courseId: String) -> CourseDetailsViewModel( courseId, @@ -264,7 +286,13 @@ val screenModule = module { ) } - viewModel { (courseId: String?) -> WhatsNewViewModel(courseId, get()) } + viewModel { (courseId: String?, infoType: String?) -> + WhatsNewViewModel( + courseId, + infoType, + get() + ) + } viewModel { HtmlUnitViewModel(get(), get(), get(), get()) } viewModel { ProgramViewModel(get(), get(), get(), get(), get(), get(), get()) } 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 ff73b7a44..a9a8357b7 100644 --- a/auth/src/main/java/org/openedx/auth/presentation/AuthRouter.kt +++ b/auth/src/main/java/org/openedx/auth/presentation/AuthRouter.kt @@ -4,19 +4,21 @@ import androidx.fragment.app.FragmentManager interface AuthRouter { - fun navigateToMain(fm: FragmentManager, courseId: String?) + fun navigateToMain(fm: FragmentManager, courseId: String?, infoType: String?) - fun navigateToSignIn(fm: FragmentManager, courseId: String?) + fun navigateToSignIn(fm: FragmentManager, courseId: String?, infoType: String?) fun navigateToLogistration(fm: FragmentManager, courseId: String?) - fun navigateToSignUp(fm: FragmentManager, courseId: String?) + fun navigateToSignUp(fm: FragmentManager, courseId: String?, infoType: String?) fun navigateToRestorePassword(fm: FragmentManager) - fun navigateToWhatsNew(fm: FragmentManager, courseId: String? = null) + fun navigateToWhatsNew(fm: FragmentManager, courseId: String? = null, infoType: String? = null) - fun navigateToDiscoverCourses(fm: FragmentManager, querySearch: String) + fun navigateToWebDiscoverCourses(fm: FragmentManager, querySearch: String) + + fun navigateToNativeDiscoverCourses(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 index 6379b246c..eb63bbc19 100644 --- a/auth/src/main/java/org/openedx/auth/presentation/logistration/LogistrationFragment.kt +++ b/auth/src/main/java/org/openedx/auth/presentation/logistration/LogistrationFragment.kt @@ -41,6 +41,7 @@ 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.config.Config import org.openedx.core.ui.AuthButtonsPanel import org.openedx.core.ui.SearchBar import org.openedx.core.ui.displayCutoutForLandscape @@ -53,6 +54,7 @@ import org.openedx.core.ui.theme.compose.LogistrationLogoView class LogistrationFragment : Fragment() { private val router: AuthRouter by inject() + private val config by inject() override fun onCreateView( inflater: LayoutInflater, @@ -63,15 +65,26 @@ class LogistrationFragment : Fragment() { setContent { OpenEdXTheme { val courseId = arguments?.getString(ARG_COURSE_ID, "") + val isDiscoveryTypeWebView = config.getDiscoveryConfig().isViewTypeWebView() LogistrationScreen( onSignInClick = { - router.navigateToSignIn(parentFragmentManager, courseId) + router.navigateToSignIn(parentFragmentManager, courseId, null) }, onRegisterClick = { - router.navigateToSignUp(parentFragmentManager, courseId) + router.navigateToSignUp(parentFragmentManager, courseId, null) }, onSearchClick = { querySearch -> - router.navigateToDiscoverCourses(parentFragmentManager, querySearch) + if (isDiscoveryTypeWebView) { + router.navigateToWebDiscoverCourses( + parentFragmentManager, + querySearch + ) + } else { + router.navigateToNativeDiscoverCourses( + parentFragmentManager, + querySearch + ) + } } ) } @@ -153,6 +166,7 @@ private fun LogistrationScreen( label = stringResource(id = R.string.pre_auth_search_hint), requestFocus = false, searchValue = textFieldValue, + clearOnSubmit = true, keyboardActions = { focusManager.clearFocus() onSearchClick(textFieldValue.text) 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 1adcaa3a1..fb613125f 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 @@ -26,7 +26,10 @@ import org.openedx.core.ui.theme.OpenEdXTheme class SignInFragment : Fragment() { private val viewModel: SignInViewModel by viewModel { - parametersOf(requireArguments().getString(ARG_COURSE_ID, null)) + parametersOf( + requireArguments().getString(ARG_COURSE_ID, ""), + requireArguments().getString(ARG_INFO_TYPE, "") + ) } private val router: AuthRouter by inject() private val whatsNewGlobalManager by inject() @@ -64,7 +67,7 @@ class SignInFragment : Fragment() { AuthEvent.RegisterClick -> { viewModel.signUpClickedEvent() - router.navigateToSignUp(parentFragmentManager, null) + router.navigateToSignUp(parentFragmentManager, null, null) } AuthEvent.BackClick -> { @@ -79,9 +82,17 @@ class SignInFragment : Fragment() { if (state.loginSuccess) { router.clearBackStack(parentFragmentManager) if (isNeedToShowWhatsNew) { - router.navigateToWhatsNew(parentFragmentManager, viewModel.courseId) + router.navigateToWhatsNew( + parentFragmentManager, + viewModel.courseId, + viewModel.infoType + ) } else { - router.navigateToMain(parentFragmentManager, viewModel.courseId) + router.navigateToMain( + parentFragmentManager, + viewModel.courseId, + viewModel.infoType + ) } } @@ -99,10 +110,12 @@ class SignInFragment : Fragment() { companion object { private const val ARG_COURSE_ID = "courseId" - fun newInstance(courseId: String?): SignInFragment { + private const val ARG_INFO_TYPE = "info_type" + fun newInstance(courseId: String?, infoType: String?): SignInFragment { val fragment = SignInFragment() fragment.arguments = bundleOf( - ARG_COURSE_ID to courseId + ARG_COURSE_ID to courseId, + ARG_INFO_TYPE to infoType ) return fragment } 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 e5532429b..d47950341 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 @@ -40,6 +40,7 @@ class SignInViewModel( private val oAuthHelper: OAuthHelper, config: Config, val courseId: String?, + val infoType: String?, ) : BaseViewModel() { private val logger = Logger("SignInViewModel") 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 97bfe45d9..2408202df 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 @@ -24,7 +24,10 @@ import org.openedx.core.ui.theme.OpenEdXTheme class SignUpFragment : Fragment() { private val viewModel by viewModel { - parametersOf(requireArguments().getString(ARG_COURSE_ID, "")) + parametersOf( + requireArguments().getString(ARG_COURSE_ID, ""), + requireArguments().getString(ARG_INFO_TYPE, "") + ) } private val router by inject() @@ -73,7 +76,11 @@ class SignUpFragment : Fragment() { LaunchedEffect(uiState.successLogin) { if (uiState.successLogin) { router.clearBackStack(requireActivity().supportFragmentManager) - router.navigateToMain(parentFragmentManager, viewModel.courseId) + router.navigateToMain( + parentFragmentManager, + viewModel.courseId, + viewModel.infoType + ) } } } else { @@ -89,10 +96,12 @@ class SignUpFragment : Fragment() { companion object { private const val ARG_COURSE_ID = "courseId" - fun newInstance(courseId: String?): SignUpFragment { + private const val ARG_INFO_TYPE = "info_type" + fun newInstance(courseId: String?, infoType: String?): SignUpFragment { val fragment = SignUpFragment() fragment.arguments = bundleOf( - ARG_COURSE_ID to courseId + ARG_COURSE_ID to courseId, + ARG_INFO_TYPE to infoType ) return fragment } 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 af1b8e094..e8ca67e93 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 @@ -38,6 +38,7 @@ class SignUpViewModel( private val oAuthHelper: OAuthHelper, private val config: Config, val courseId: String?, + val infoType: String?, ) : BaseViewModel() { private val logger = Logger("SignUpViewModel") 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 16b5032c4..6b04b596b 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 @@ -100,6 +100,7 @@ class SignInViewModelTest { oAuthHelper = oAuthHelper, config = config, courseId = "", + infoType = "", ) viewModel.login("", "") coVerify(exactly = 0) { interactor.login(any(), any()) } @@ -127,6 +128,7 @@ class SignInViewModelTest { oAuthHelper = oAuthHelper, config = config, courseId = "", + infoType = "", ) viewModel.login("acc@test.o", "") coVerify(exactly = 0) { interactor.login(any(), any()) } @@ -156,6 +158,7 @@ class SignInViewModelTest { oAuthHelper = oAuthHelper, config = config, courseId = "", + infoType = "", ) viewModel.login("acc@test.org", "") @@ -184,6 +187,7 @@ class SignInViewModelTest { oAuthHelper = oAuthHelper, config = config, courseId = "", + infoType = "", ) viewModel.login("acc@test.org", "ed") @@ -214,6 +218,7 @@ class SignInViewModelTest { oAuthHelper = oAuthHelper, config = config, courseId = "", + infoType = "", ) coEvery { interactor.login("acc@test.org", "edx") } returns Unit viewModel.login("acc@test.org", "edx") @@ -245,6 +250,7 @@ class SignInViewModelTest { oAuthHelper = oAuthHelper, config = config, courseId = "", + infoType = "", ) coEvery { interactor.login("acc@test.org", "edx") } throws UnknownHostException() viewModel.login("acc@test.org", "edx") @@ -277,6 +283,7 @@ class SignInViewModelTest { oAuthHelper = oAuthHelper, config = config, courseId = "", + infoType = "", ) coEvery { interactor.login("acc@test.org", "edx") } throws EdxError.InvalidGrantException() viewModel.login("acc@test.org", "edx") @@ -309,6 +316,7 @@ class SignInViewModelTest { oAuthHelper = oAuthHelper, config = config, courseId = "", + infoType = "", ) coEvery { interactor.login("acc@test.org", "edx") } throws IllegalStateException() viewModel.login("acc@test.org", "edx") 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 bd048902c..f93447bb8 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 @@ -129,6 +129,7 @@ class SignUpViewModelTest { oAuthHelper = oAuthHelper, config = config, courseId = "", + infoType = "", ) coEvery { interactor.validateRegistrationFields(parametersMap) } returns ValidationFields( parametersMap @@ -169,6 +170,7 @@ class SignUpViewModelTest { oAuthHelper = oAuthHelper, config = config, courseId = "", + infoType = "", ) val deferred = async { viewModel.uiMessage.first() } @@ -215,6 +217,7 @@ class SignUpViewModelTest { oAuthHelper = oAuthHelper, config = config, courseId = "", + infoType = "", ) val deferred = async { viewModel.uiMessage.first() } @@ -251,6 +254,7 @@ class SignUpViewModelTest { oAuthHelper = oAuthHelper, config = config, courseId = "", + infoType = "", ) coEvery { interactor.validateRegistrationFields(parametersMap) } returns ValidationFields( emptyMap() @@ -298,6 +302,7 @@ class SignUpViewModelTest { oAuthHelper = oAuthHelper, config = config, courseId = "", + infoType = "", ) val deferred = async { viewModel.uiMessage.first() } @@ -322,6 +327,7 @@ class SignUpViewModelTest { oAuthHelper = oAuthHelper, config = config, courseId = "", + infoType = "", ) val deferred = async { viewModel.uiMessage.first() } @@ -346,6 +352,7 @@ class SignUpViewModelTest { oAuthHelper = oAuthHelper, config = config, courseId = "", + infoType = "", ) coEvery { interactor.getRegistrationFields() } returns listOfFields viewModel.getRegistrationFields() 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 80f61d75d..b5b4dca7c 100644 --- a/core/src/main/java/org/openedx/core/ui/ComposeCommon.kt +++ b/core/src/main/java/org/openedx/core/ui/ComposeCommon.kt @@ -1,8 +1,6 @@ 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 @@ -177,6 +175,7 @@ fun SearchBar( searchValue: TextFieldValue, requestFocus: Boolean = false, label: String = stringResource(id = R.string.core_search), + clearOnSubmit: Boolean = false, keyboardActions: () -> Unit, onValueChanged: (TextFieldValue) -> Unit = {}, onClearValue: () -> Unit, @@ -258,6 +257,10 @@ fun SearchBar( keyboardActions = KeyboardActions { keyboardController?.hide() keyboardActions() + if (clearOnSubmit) { + textFieldValue = TextFieldValue("") + onClearValue() + } }, textStyle = MaterialTheme.appTypography.bodyMedium, maxLines = 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 index e90f0dfae..191edd4da 100644 --- a/core/src/main/java/org/openedx/core/utils/UrlUtils.kt +++ b/core/src/main/java/org/openedx/core/utils/UrlUtils.kt @@ -5,6 +5,9 @@ import android.content.Intent import android.net.Uri object UrlUtils { + + const val QUERY_PARAM_SEARCH = "q" + fun openInBrowser(activity: Context, apiHostUrl: String, url: String) { if (url.isEmpty()) { return @@ -23,4 +26,42 @@ object UrlUtils { intent.setData(Uri.parse(url)) context.startActivity(intent) } + + /** + * Utility function to remove the given query parameter from the URL + * Ref: https://stackoverflow.com/a/56108097 + * + * @param url that needs to update + * @param queryParam that needs to remove from the URL + * @return The URL after removing the given params + */ + private fun removeQueryParameterFromURL(url: String, queryParam: String): String { + val uri = Uri.parse(url) + val params = uri.queryParameterNames + val newUri = uri.buildUpon().clearQuery() + for (param in params) { + if (queryParam != param) { + newUri.appendQueryParameter(param, uri.getQueryParameter(param)) + } + } + return newUri.build().toString() + } + + /** + * Builds a valid URL with the given query params. + * + * @param url The base URL. + * @param queryParams The query params to add in the URL. + * @return URL String with query params added to it. + */ + fun buildUrlWithQueryParams(url: String, queryParams: Map): String { + val uriBuilder = Uri.parse(url).buildUpon() + for ((key, value) in queryParams) { + if (url.contains(key)) { + removeQueryParameterFromURL(url, key) + } + uriBuilder.appendQueryParameter(key, value) + } + return uriBuilder.build().toString() + } } diff --git a/course/src/main/java/org/openedx/course/presentation/CourseRouter.kt b/course/src/main/java/org/openedx/course/presentation/CourseRouter.kt index 05a4f63a6..2eafb8d97 100644 --- a/course/src/main/java/org/openedx/course/presentation/CourseRouter.kt +++ b/course/src/main/java/org/openedx/course/presentation/CourseRouter.kt @@ -68,9 +68,9 @@ interface CourseRouter { infoType: String, ) - fun navigateToSignUp(fm: FragmentManager, courseId: String?) + fun navigateToSignUp(fm: FragmentManager, courseId: String?, infoType: String?) - fun navigateToSignIn(fm: FragmentManager, courseId: String?) + fun navigateToSignIn(fm: FragmentManager, courseId: String?, infoType: String?) fun navigateToLogistration(fm: FragmentManager, courseId: String?) } diff --git a/course/src/main/java/org/openedx/course/presentation/detail/CourseDetailsFragment.kt b/course/src/main/java/org/openedx/course/presentation/detail/CourseDetailsFragment.kt index d7fe21eaa..cc7a500f4 100644 --- a/course/src/main/java/org/openedx/course/presentation/detail/CourseDetailsFragment.kt +++ b/course/src/main/java/org/openedx/course/presentation/detail/CourseDetailsFragment.kt @@ -126,10 +126,10 @@ class CourseDetailsFragment : Fragment() { } }, onRegisterClick = { - router.navigateToSignUp(parentFragmentManager, viewModel.courseId) + router.navigateToSignUp(parentFragmentManager, viewModel.courseId, null) }, onSignInClick = { - router.navigateToSignIn(parentFragmentManager, viewModel.courseId) + router.navigateToSignIn(parentFragmentManager, viewModel.courseId, null) }, ) } diff --git a/course/src/main/java/org/openedx/course/presentation/info/CourseInfoFragment.kt b/course/src/main/java/org/openedx/course/presentation/info/CourseInfoFragment.kt index 852b3a5ca..a2f8ba0ec 100644 --- a/course/src/main/java/org/openedx/course/presentation/info/CourseInfoFragment.kt +++ b/course/src/main/java/org/openedx/course/presentation/info/CourseInfoFragment.kt @@ -40,10 +40,13 @@ 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.koin.core.parameter.parametersOf import org.openedx.core.UIMessage 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.AuthButtonsPanel import org.openedx.core.ui.ConnectionErrorView import org.openedx.core.ui.HandleUIMessage import org.openedx.core.ui.Toolbar @@ -56,12 +59,18 @@ import org.openedx.core.ui.theme.OpenEdXTheme import org.openedx.core.ui.theme.appColors import org.openedx.core.ui.windowSizeValue import org.openedx.course.R +import java.util.concurrent.atomic.AtomicReference import org.openedx.core.R as CoreR import org.openedx.core.presentation.catalog.WebViewLink.Authority as linkAuthority class CourseInfoFragment : Fragment() { - private val viewModel by viewModel() + private val viewModel by viewModel { + parametersOf( + requireArguments().getString(ARG_PATH_ID, ""), + requireArguments().getString(ARG_INFO_TYPE, "") + ) + } override fun onCreateView( inflater: LayoutInflater, @@ -73,7 +82,7 @@ class CourseInfoFragment : Fragment() { OpenEdXTheme { val uiMessage by viewModel.uiMessage.collectAsState(initial = null) val showAlert by viewModel.showAlert.collectAsState(initial = false) - val enrollmentSuccess by viewModel.courseEnrollSuccess.collectAsState(initial = "") + val uiState by viewModel.uiState.collectAsState() val windowSize = rememberWindowSize() var hasInternetConnection by remember { mutableStateOf(viewModel.hasInternetConnection) @@ -94,24 +103,40 @@ class CourseInfoFragment : Fragment() { } } - LaunchedEffect(enrollmentSuccess) { - if (enrollmentSuccess.isNotEmpty()) { + LaunchedEffect(uiState.enrollmentSuccess.get()) { + if (uiState.enrollmentSuccess.get().isNotEmpty()) { viewModel.onSuccessfulCourseEnrollment( fragmentManager = requireActivity().supportFragmentManager, - courseId = enrollmentSuccess, + courseId = uiState.enrollmentSuccess.get(), ) + // Clear after navigation + uiState.enrollmentSuccess.set("") } } CourseInfoScreen( windowSize = windowSize, + uiState = uiState, uiMessage = uiMessage, - contentUrl = getInitialUrl(), uriScheme = viewModel.uriScheme, hasInternetConnection = hasInternetConnection, checkInternetConnection = { hasInternetConnection = viewModel.hasInternetConnection }, + onRegisterClick = { + viewModel.navigateToSignUp( + parentFragmentManager, + viewModel.pathId, + viewModel.infoType + ) + }, + onSignInClick = { + viewModel.navigateToSignIn( + parentFragmentManager, + viewModel.pathId, + viewModel.infoType + ) + }, onBackClick = { requireActivity().supportFragmentManager.popBackStackImmediate() }, @@ -141,7 +166,15 @@ class CourseInfoFragment : Fragment() { } linkAuthority.ENROLL -> { - viewModel.enrollInACourse(param) + if (uiState.isPreLogin) { + viewModel.navigateToSignUp( + fragmentManager = requireActivity().supportFragmentManager, + courseId = viewModel.pathId, + infoType = viewModel.infoType + ) + } else { + viewModel.enrollInACourse(courseId = param) + } } else -> {} @@ -152,18 +185,6 @@ class CourseInfoFragment : Fragment() { } } - private fun getInitialUrl(): String { - return arguments?.let { args -> - val pathId = args.getString(ARG_PATH_ID) ?: "" - val urlTemplate = if (args.getString(ARG_INFO_TYPE) == linkAuthority.COURSE_INFO.name) { - viewModel.webViewConfig.courseUrlTemplate - } else { - viewModel.webViewConfig.programUrlTemplate - } - urlTemplate.replace("{$ARG_PATH_ID}", pathId) - } ?: viewModel.webViewConfig.baseUrl - } - companion object { private const val ARG_PATH_ID = "path_id" private const val ARG_INFO_TYPE = "info_type" @@ -185,13 +206,15 @@ class CourseInfoFragment : Fragment() { @Composable private fun CourseInfoScreen( windowSize: WindowSize, + uiState: CourseInfoUIState, uiMessage: UIMessage?, - contentUrl: String, uriScheme: String, hasInternetConnection: Boolean, checkInternetConnection: () -> Unit, + onRegisterClick: () -> Unit, + onSignInClick: () -> Unit, onBackClick: () -> Unit, - onUriClick: (String, linkAuthority) -> Unit, + onUriClick: (String, WebViewLink.Authority) -> Unit, ) { val scaffoldState = rememberScaffoldState() val configuration = LocalConfiguration.current @@ -202,7 +225,23 @@ private fun CourseInfoScreen( Scaffold( scaffoldState = scaffoldState, modifier = Modifier.fillMaxSize(), - backgroundColor = MaterialTheme.appColors.background + backgroundColor = MaterialTheme.appColors.background, + bottomBar = { + if (uiState.isPreLogin) { + Box( + modifier = Modifier + .padding( + horizontal = 16.dp, + vertical = 32.dp, + ) + ) { + AuthButtonsPanel( + onRegisterClick = onRegisterClick, + onSignInClick = onSignInClick + ) + } + } + } ) { val modifierScreenWidth by remember(key1 = windowSize) { mutableStateOf( @@ -240,7 +279,7 @@ private fun CourseInfoScreen( ) { if (hasInternetConnection) { CourseInfoWebView( - contentUrl = contentUrl, + contentUrl = uiState.initialUrl, uriScheme = uriScheme, onWebPageLoaded = { isLoading = false }, onUriClick = onUriClick, @@ -307,11 +346,17 @@ fun CourseInfoScreenPreview() { OpenEdXTheme { CourseInfoScreen( windowSize = WindowSize(WindowType.Compact, WindowType.Compact), + uiState = CourseInfoUIState( + initialUrl = "https://www.example.com/", + isPreLogin = false, + enrollmentSuccess = AtomicReference("") + ), uiMessage = null, - contentUrl = "https://www.example.com/", uriScheme = "", hasInternetConnection = false, checkInternetConnection = {}, + onRegisterClick = {}, + onSignInClick = {}, onBackClick = {}, onUriClick = { _, _ -> }, ) diff --git a/course/src/main/java/org/openedx/course/presentation/info/CourseInfoUIState.kt b/course/src/main/java/org/openedx/course/presentation/info/CourseInfoUIState.kt new file mode 100644 index 000000000..e75a7873e --- /dev/null +++ b/course/src/main/java/org/openedx/course/presentation/info/CourseInfoUIState.kt @@ -0,0 +1,9 @@ +package org.openedx.course.presentation.info + +import java.util.concurrent.atomic.AtomicReference + +internal data class CourseInfoUIState( + val initialUrl: String = "", + val isPreLogin: Boolean = false, + val enrollmentSuccess: AtomicReference = AtomicReference("") +) diff --git a/course/src/main/java/org/openedx/course/presentation/info/CourseInfoViewModel.kt b/course/src/main/java/org/openedx/course/presentation/info/CourseInfoViewModel.kt index a70411049..f2da38168 100644 --- a/course/src/main/java/org/openedx/course/presentation/info/CourseInfoViewModel.kt +++ b/course/src/main/java/org/openedx/course/presentation/info/CourseInfoViewModel.kt @@ -3,13 +3,18 @@ package org.openedx.course.presentation.info import androidx.fragment.app.FragmentManager import androidx.lifecycle.viewModelScope import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import org.openedx.core.BaseViewModel import org.openedx.core.UIMessage import org.openedx.core.config.Config +import org.openedx.core.data.storage.CorePreferences import org.openedx.core.extension.isInternetError +import org.openedx.core.presentation.catalog.WebViewLink import org.openedx.core.system.ResourceManager import org.openedx.core.system.connection.NetworkConnection import org.openedx.core.system.notifier.CourseDashboardUpdate @@ -17,6 +22,7 @@ import org.openedx.core.system.notifier.CourseNotifier import org.openedx.course.R import org.openedx.course.domain.interactor.CourseInteractor import org.openedx.course.presentation.CourseRouter +import java.util.concurrent.atomic.AtomicReference import org.openedx.core.R as CoreR class CourseInfoViewModel( @@ -26,8 +32,20 @@ class CourseInfoViewModel( private val interactor: CourseInteractor, private val notifier: CourseNotifier, private val resourceManager: ResourceManager, + corePreferences: CorePreferences, + val pathId: String, + val infoType: String, ) : BaseViewModel() { + private val _uiState = + MutableStateFlow( + CourseInfoUIState( + initialUrl = getInitialUrl(), + isPreLogin = config.isPreLoginExperienceEnabled() && corePreferences.user == null + ) + ) + internal val uiState: StateFlow = _uiState + private val _uiMessage = MutableSharedFlow() val uiMessage: SharedFlow get() = _uiMessage.asSharedFlow() @@ -36,16 +54,25 @@ class CourseInfoViewModel( val showAlert: SharedFlow get() = _showAlert.asSharedFlow() - private val _courseEnrollSuccess = MutableSharedFlow() - val courseEnrollSuccess: SharedFlow - get() = _courseEnrollSuccess.asSharedFlow() - val hasInternetConnection: Boolean get() = networkConnection.isOnline() val uriScheme: String get() = config.getUriScheme() - val webViewConfig get() = config.getDiscoveryConfig().webViewConfig + private val webViewConfig get() = config.getDiscoveryConfig().webViewConfig + + private fun getInitialUrl(): String { + val urlTemplate = when (infoType) { + WebViewLink.Authority.COURSE_INFO.name -> webViewConfig.courseUrlTemplate + WebViewLink.Authority.PROGRAM_INFO.name -> webViewConfig.programUrlTemplate + else -> webViewConfig.baseUrl + } + return if (pathId.isEmpty() || infoType.isEmpty()) { + webViewConfig.baseUrl + } else { + urlTemplate.replace("{${ARG_PATH_ID}}", pathId) + } + } fun enrollInACourse(courseId: String) { viewModelScope.launch { @@ -57,14 +84,13 @@ class CourseInfoViewModel( _uiMessage.emit( UIMessage.ToastMessage(resourceManager.getString(R.string.course_you_are_already_enrolled)) ) - _courseEnrollSuccess.emit(courseId) + _uiState.update { it.copy(enrollmentSuccess = AtomicReference(courseId)) } return@launch } interactor.enrollInACourse(courseId) notifier.send(CourseDashboardUpdate()) - _courseEnrollSuccess.emit(courseId) - + _uiState.update { it.copy(enrollmentSuccess = AtomicReference(courseId)) } } catch (e: Exception) { if (e.isInternetError()) { _uiMessage.emit( @@ -96,4 +122,16 @@ class CourseInfoViewModel( ) } } + + fun navigateToSignUp(fragmentManager: FragmentManager, courseId: String?, infoType: String) { + router.navigateToSignUp(fragmentManager, courseId, infoType) + } + + fun navigateToSignIn(fragmentManager: FragmentManager, courseId: String, infoType: String) { + router.navigateToSignIn(fragmentManager, courseId, infoType) + } + + companion object { + private const val ARG_PATH_ID = "path_id" + } } diff --git a/discovery/src/main/java/org/openedx/discovery/presentation/DiscoveryNavigator.kt b/discovery/src/main/java/org/openedx/discovery/presentation/DiscoveryNavigator.kt index ff2e32178..2d49f7723 100644 --- a/discovery/src/main/java/org/openedx/discovery/presentation/DiscoveryNavigator.kt +++ b/discovery/src/main/java/org/openedx/discovery/presentation/DiscoveryNavigator.kt @@ -7,9 +7,9 @@ class DiscoveryNavigator( ) { fun getDiscoveryFragment(): Fragment { return if (isDiscoveryTypeWebView) { - WebViewDiscoveryFragment() + WebViewDiscoveryFragment.newInstance() } else { - NativeDiscoveryFragment() + NativeDiscoveryFragment.newInstance() } } } 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 fa66c542b..53a93f4c1 100644 --- a/discovery/src/main/java/org/openedx/discovery/presentation/DiscoveryRouter.kt +++ b/discovery/src/main/java/org/openedx/discovery/presentation/DiscoveryRouter.kt @@ -12,7 +12,7 @@ interface DiscoveryRouter { fun navigateToCourseInfo(fm: FragmentManager, courseId: String, infoType: String) - fun navigateToSignUp(fm: FragmentManager, courseId: String? = null) + fun navigateToSignUp(fm: FragmentManager, courseId: String? = null, infoType: String? = null) - fun navigateToSignIn(fm: FragmentManager, courseId: String?) + fun navigateToSignIn(fm: FragmentManager, courseId: String?, infoType: String?) } diff --git a/discovery/src/main/java/org/openedx/discovery/presentation/NativeDiscoveryFragment.kt b/discovery/src/main/java/org/openedx/discovery/presentation/NativeDiscoveryFragment.kt index f68785720..b64e3b5b8 100644 --- a/discovery/src/main/java/org/openedx/discovery/presentation/NativeDiscoveryFragment.kt +++ b/discovery/src/main/java/org/openedx/discovery/presentation/NativeDiscoveryFragment.kt @@ -153,10 +153,10 @@ class NativeDiscoveryFragment : Fragment() { ) }, onRegisterClick = { - router.navigateToSignUp(parentFragmentManager, null) + router.navigateToSignUp(parentFragmentManager, null, null) }, onSignInClick = { - router.navigateToSignIn(parentFragmentManager, null) + router.navigateToSignIn(parentFragmentManager, null, null) }, onBackClick = { requireActivity().supportFragmentManager.popBackStackImmediate() @@ -175,7 +175,7 @@ class NativeDiscoveryFragment : Fragment() { companion object { private const val ARG_SEARCH_QUERY = "query_search" - fun newInstance(querySearch: String): NativeDiscoveryFragment { + fun newInstance(querySearch: String = ""): NativeDiscoveryFragment { val fragment = NativeDiscoveryFragment() fragment.arguments = bundleOf( ARG_SEARCH_QUERY to querySearch diff --git a/discovery/src/main/java/org/openedx/discovery/presentation/WebViewDiscoveryFragment.kt b/discovery/src/main/java/org/openedx/discovery/presentation/WebViewDiscoveryFragment.kt index a30d6075d..33a2bd24a 100644 --- a/discovery/src/main/java/org/openedx/discovery/presentation/WebViewDiscoveryFragment.kt +++ b/discovery/src/main/java/org/openedx/discovery/presentation/WebViewDiscoveryFragment.kt @@ -40,14 +40,17 @@ 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 androidx.lifecycle.Lifecycle import androidx.lifecycle.LifecycleEventObserver import androidx.lifecycle.LifecycleOwner import org.koin.androidx.viewmodel.ext.android.viewModel +import org.koin.core.parameter.parametersOf 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.AuthButtonsPanel import org.openedx.core.ui.ConnectionErrorView import org.openedx.core.ui.Toolbar import org.openedx.core.ui.WindowSize @@ -63,7 +66,9 @@ import org.openedx.core.R as CoreR class WebViewDiscoveryFragment : Fragment() { - private val viewModel by viewModel() + private val viewModel by viewModel { + parametersOf(requireArguments().getString(ARG_SEARCH_QUERY, "")) + } override fun onCreateView( inflater: LayoutInflater, @@ -77,9 +82,9 @@ class WebViewDiscoveryFragment : Fragment() { var hasInternetConnection by remember { mutableStateOf(viewModel.hasInternetConnection) } - WebViewDiscoveryScreen( windowSize = windowSize, + isPreLogin = viewModel.isPreLogin, contentUrl = viewModel.discoveryUrl, uriScheme = viewModel.uriScheme, hasInternetConnection = hasInternetConnection, @@ -116,23 +121,47 @@ class WebViewDiscoveryFragment : Fragment() { else -> {} } + }, + onRegisterClick = { + viewModel.navigateToSignUp(parentFragmentManager) + }, + onSignInClick = { + viewModel.navigateToSignIn(parentFragmentManager) + }, + onBackClick = { + requireActivity().supportFragmentManager.popBackStackImmediate() } ) } } } + + companion object { + + private const val ARG_SEARCH_QUERY = "query_search" + + fun newInstance(querySearch: String = ""): WebViewDiscoveryFragment { + val fragment = WebViewDiscoveryFragment() + fragment.arguments = bundleOf(ARG_SEARCH_QUERY to querySearch) + return fragment + } + } } @Composable @SuppressLint("SetJavaScriptEnabled") private fun WebViewDiscoveryScreen( windowSize: WindowSize, + isPreLogin: Boolean, contentUrl: String, uriScheme: String, hasInternetConnection: Boolean, checkInternetConnection: () -> Unit, onWebPageUpdated: (String) -> Unit, - onUriClick: (String, WebViewLink.Authority) -> Unit + onUriClick: (String, WebViewLink.Authority) -> Unit, + onRegisterClick: () -> Unit, + onSignInClick: () -> Unit, + onBackClick: () -> Unit ) { val scaffoldState = rememberScaffoldState() val configuration = LocalConfiguration.current @@ -141,7 +170,23 @@ private fun WebViewDiscoveryScreen( Scaffold( scaffoldState = scaffoldState, modifier = Modifier.fillMaxSize(), - backgroundColor = MaterialTheme.appColors.background + backgroundColor = MaterialTheme.appColors.background, + bottomBar = { + if (isPreLogin) { + Box( + modifier = Modifier + .padding( + horizontal = 16.dp, + vertical = 32.dp, + ) + ) { + AuthButtonsPanel( + onRegisterClick = onRegisterClick, + onSignInClick = onSignInClick + ) + } + } + } ) { val modifierScreenWidth by remember(key1 = windowSize) { mutableStateOf( @@ -164,7 +209,11 @@ private fun WebViewDiscoveryScreen( .displayCutoutForLandscape(), horizontalAlignment = Alignment.CenterHorizontally, ) { - Toolbar(label = stringResource(id = R.string.discovery_explore_the_catalog)) + Toolbar( + label = stringResource(id = R.string.discovery_explore_the_catalog), + canShowBackBtn = isPreLogin, + onBackClick = onBackClick + ) Surface { Box( @@ -286,11 +335,15 @@ private fun WebViewDiscoveryScreenPreview() { WebViewDiscoveryScreen( windowSize = WindowSize(WindowType.Compact, WindowType.Compact), contentUrl = "https://www.example.com/", + isPreLogin = false, uriScheme = "", hasInternetConnection = false, checkInternetConnection = {}, onWebPageUpdated = {}, onUriClick = { _, _ -> }, + onRegisterClick = {}, + onSignInClick = {}, + onBackClick = {} ) } } diff --git a/discovery/src/main/java/org/openedx/discovery/presentation/WebViewDiscoveryViewModel.kt b/discovery/src/main/java/org/openedx/discovery/presentation/WebViewDiscoveryViewModel.kt index a25bc801e..7fa72c67f 100644 --- a/discovery/src/main/java/org/openedx/discovery/presentation/WebViewDiscoveryViewModel.kt +++ b/discovery/src/main/java/org/openedx/discovery/presentation/WebViewDiscoveryViewModel.kt @@ -3,21 +3,35 @@ 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.data.storage.CorePreferences import org.openedx.core.system.connection.NetworkConnection +import org.openedx.core.utils.UrlUtils class WebViewDiscoveryViewModel( private val config: Config, private val networkConnection: NetworkConnection, + private val corePreferences: CorePreferences, private val router: DiscoveryRouter, + private val querySearch: String, ) : BaseViewModel() { val uriScheme: String get() = config.getUriScheme() - val webViewConfig get() = config.getDiscoveryConfig().webViewConfig + private val webViewConfig get() = config.getDiscoveryConfig().webViewConfig + + val isPreLogin get() = config.isPreLoginExperienceEnabled() && corePreferences.user == null private var _discoveryUrl = webViewConfig.baseUrl val discoveryUrl: String - get() = _discoveryUrl + get() { + return if (querySearch.isNotBlank()) { + val queryParams: MutableMap = HashMap() + queryParams[UrlUtils.QUERY_PARAM_SEARCH] = querySearch + UrlUtils.buildUrlWithQueryParams(_discoveryUrl, queryParams) + } else { + _discoveryUrl + } + } val hasInternetConnection: Boolean get() = networkConnection.isOnline() @@ -37,4 +51,12 @@ class WebViewDiscoveryViewModel( ) } } + + fun navigateToSignUp(fragmentManager: FragmentManager) { + router.navigateToSignUp(fragmentManager, null) + } + + fun navigateToSignIn(fragmentManager: FragmentManager) { + router.navigateToSignIn(fragmentManager, null, null) + } } 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 9329887fc..26af85022 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 @@ -134,10 +134,10 @@ class CourseSearchFragment : Fragment() { ) }, onRegisterClick = { - router.navigateToSignUp(parentFragmentManager, null) + router.navigateToSignUp(parentFragmentManager, null, null) }, onSignInClick = { - router.navigateToSignIn(parentFragmentManager, null) + router.navigateToSignIn(parentFragmentManager, null, null) }, ) } diff --git a/whatsnew/src/main/java/org/openedx/whatsnew/WhatsNewRouter.kt b/whatsnew/src/main/java/org/openedx/whatsnew/WhatsNewRouter.kt index 46bbe3d3d..a8d1cd463 100644 --- a/whatsnew/src/main/java/org/openedx/whatsnew/WhatsNewRouter.kt +++ b/whatsnew/src/main/java/org/openedx/whatsnew/WhatsNewRouter.kt @@ -3,5 +3,5 @@ package org.openedx.whatsnew import androidx.fragment.app.FragmentManager interface WhatsNewRouter { - fun navigateToMain(fm: FragmentManager, courseId: String? = null) + fun navigateToMain(fm: FragmentManager, courseId: String? = null, infoType: String? = null) } 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 index d1d69b861..6ba02e558 100644 --- a/whatsnew/src/main/java/org/openedx/whatsnew/presentation/whatsnew/WhatsNewFragment.kt +++ b/whatsnew/src/main/java/org/openedx/whatsnew/presentation/whatsnew/WhatsNewFragment.kt @@ -76,7 +76,10 @@ import org.openedx.whatsnew.presentation.ui.PageIndicator class WhatsNewFragment : Fragment() { private val viewModel: WhatsNewViewModel by viewModel { - parametersOf(requireArguments().getString(ARG_COURSE_ID, null)) + parametersOf( + requireArguments().getString(ARG_COURSE_ID, null), + requireArguments().getString(ARG_INFO_TYPE, null) + ) } private val preferencesManager by inject() private val router by inject() @@ -98,7 +101,11 @@ class WhatsNewFragment : Fragment() { onCloseClick = { val versionName = appData.versionName preferencesManager.lastWhatsNewVersion = versionName - router.navigateToMain(parentFragmentManager, viewModel.courseId) + router.navigateToMain( + parentFragmentManager, + viewModel.courseId, + viewModel.infoType + ) } ) } @@ -107,10 +114,13 @@ class WhatsNewFragment : Fragment() { companion object { private const val ARG_COURSE_ID = "courseId" - fun newInstance(courseId: String? = null): WhatsNewFragment { + private const val ARG_INFO_TYPE = "info_type" + + fun newInstance(courseId: String? = null, infoType: String? = null): WhatsNewFragment { val fragment = WhatsNewFragment() fragment.arguments = bundleOf( - ARG_COURSE_ID to courseId + ARG_COURSE_ID to courseId, + ARG_INFO_TYPE to infoType ) return fragment } 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 index c27ead37c..ba4ee3ee5 100644 --- a/whatsnew/src/main/java/org/openedx/whatsnew/presentation/whatsnew/WhatsNewViewModel.kt +++ b/whatsnew/src/main/java/org/openedx/whatsnew/presentation/whatsnew/WhatsNewViewModel.kt @@ -8,6 +8,7 @@ import org.openedx.whatsnew.domain.model.WhatsNewItem class WhatsNewViewModel( val courseId: String?, + val infoType: String?, private val whatsNewManager: WhatsNewManager ) : BaseViewModel() { diff --git a/whatsnew/src/test/java/org/openedx/whatsnew/WhatsNewViewModelTest.kt b/whatsnew/src/test/java/org/openedx/whatsnew/WhatsNewViewModelTest.kt index e187ffaa8..307946b19 100644 --- a/whatsnew/src/test/java/org/openedx/whatsnew/WhatsNewViewModelTest.kt +++ b/whatsnew/src/test/java/org/openedx/whatsnew/WhatsNewViewModelTest.kt @@ -21,7 +21,7 @@ class WhatsNewViewModelTest { fun `getNewestData success`() = runTest { every { whatsNewManager.getNewestData() } returns whatsNewItem - val viewModel = WhatsNewViewModel("", whatsNewManager) + val viewModel = WhatsNewViewModel("", "", whatsNewManager) verify(exactly = 1) { whatsNewManager.getNewestData() } assert(viewModel.whatsNewItem.value == whatsNewItem) From f4731df847e30289996c1bf7e3331faf0996ce6d Mon Sep 17 00:00:00 2001 From: Farhan Arshad <43750646+farhan-arshad-dev@users.noreply.github.com> Date: Wed, 14 Feb 2024 15:13:04 +0500 Subject: [PATCH 04/39] feat: Segment SDK implementation and destinations (#227) feat: Integration Segment Kotlin SDK - Fetch segment required key from config. - Create Analytics Manager for segment. - Replace the bundle with the map for the analytics manager. - Add Firebase destination to make a connection with the segment if Firebase config Analytics source is segment. - Add Braze destination to make a connection with segment If firebase config Analytics source is `segment` and `Braze` is enabled. docs: - https://segment.com/docs/connections/sources/catalog/libraries/mobile/kotlin-android/ - https://segment.com/docs/connections/sources/catalog/libraries/mobile/kotlin-android/destination-plugins/firebase-kotlin-android/ - https://github.com/braze-inc/braze-segment-kotlin fixes: LEARNER-9827 --- app/build.gradle | 7 + .../java/org/openedx/app/AnalyticsManager.kt | 337 +++++++----------- .../org/openedx/app/analytics/Analytics.kt | 6 +- .../app/analytics/FirebaseAnalytics.kt | 27 +- .../openedx/app/analytics/SegmentAnalytics.kt | 56 +++ .../openedx/core/config/AnalyticsSource.kt | 11 + .../org/openedx/core/config/BrazeConfig.kt | 11 + .../java/org/openedx/core/config/Config.kt | 10 + .../org/openedx/core/config/FirebaseConfig.kt | 9 +- .../org/openedx/core/config/SegmentConfig.kt | 11 + .../java/org/openedx/core/extension/MapExt.kt | 13 + default_config/dev/config.yaml | 17 + default_config/prod/config.yaml | 17 + default_config/stage/config.yaml | 17 + 14 files changed, 326 insertions(+), 223 deletions(-) create mode 100644 app/src/main/java/org/openedx/app/analytics/SegmentAnalytics.kt create mode 100644 core/src/main/java/org/openedx/core/config/AnalyticsSource.kt create mode 100644 core/src/main/java/org/openedx/core/config/BrazeConfig.kt create mode 100644 core/src/main/java/org/openedx/core/config/SegmentConfig.kt create mode 100644 core/src/main/java/org/openedx/core/extension/MapExt.kt diff --git a/app/build.gradle b/app/build.gradle index a0f268eb6..45b8abb92 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -106,6 +106,13 @@ dependencies { implementation 'androidx.core:core-splashscreen:1.0.1' + // Segment Library + implementation "com.segment.analytics.kotlin:android:1.14.2" + // Segment's Firebase integration + implementation 'com.segment.analytics.kotlin.destinations:firebase:1.5.2' + // Braze SDK Integration + implementation "com.braze:braze-segment-kotlin:1.4.2" + androidTestImplementation 'androidx.test.ext:junit:1.1.5' androidTestImplementation 'androidx.test.espresso:espresso-core:3.5.1' androidTestImplementation "androidx.compose.ui:ui-test-junit4:$compose_version" diff --git a/app/src/main/java/org/openedx/app/AnalyticsManager.kt b/app/src/main/java/org/openedx/app/AnalyticsManager.kt index 4f152677c..d2604ca1d 100644 --- a/app/src/main/java/org/openedx/app/AnalyticsManager.kt +++ b/app/src/main/java/org/openedx/app/AnalyticsManager.kt @@ -1,10 +1,9 @@ package org.openedx.app import android.content.Context -import android.os.Bundle -import androidx.core.os.bundleOf import org.openedx.app.analytics.Analytics import org.openedx.app.analytics.FirebaseAnalytics +import org.openedx.app.analytics.SegmentAnalytics import org.openedx.auth.presentation.AuthAnalytics import org.openedx.core.config.Config import org.openedx.course.presentation.CourseAnalytics @@ -16,23 +15,27 @@ import org.openedx.profile.presentation.ProfileAnalytics class AnalyticsManager( context: Context, config: Config, -) : DashboardAnalytics, AuthAnalytics, AppAnalytics, - DiscoveryAnalytics, ProfileAnalytics, CourseAnalytics, DiscussionAnalytics { +) : DashboardAnalytics, AuthAnalytics, AppAnalytics, DiscoveryAnalytics, ProfileAnalytics, + CourseAnalytics, DiscussionAnalytics { private val services: ArrayList = arrayListOf() init { // Initialise all the analytics libraries here - if (config.getFirebaseConfig().projectId.isNotEmpty()) { + if (config.getFirebaseConfig().projectId.isNotBlank()) { addAnalyticsTracker(FirebaseAnalytics(context = context)) } + val segmentConfig = config.getSegmentConfig() + if (segmentConfig.enabled && segmentConfig.segmentWriteKey.isNotBlank()) { + addAnalyticsTracker(SegmentAnalytics(context = context, config = config)) + } } private fun addAnalyticsTracker(analytic: Analytics) { services.add(analytic) } - private fun logEvent(event: Event, params: Bundle = bundleOf()) { + private fun logEvent(event: Event, params: Map = mapOf()) { services.forEach { analytics -> analytics.logEvent(event.eventName, params) } @@ -45,22 +48,16 @@ class AnalyticsManager( } override fun dashboardCourseClickedEvent(courseId: String, courseName: String) { - logEvent( - Event.DASHBOARD_COURSE_CLICKED, - bundleOf( - Key.COURSE_ID.keyName to courseId, - Key.COURSE_NAME.keyName to courseName - ) - ) + logEvent(Event.DASHBOARD_COURSE_CLICKED, buildMap { + put(Key.COURSE_ID.keyName, courseId) + put(Key.COURSE_NAME.keyName, courseName) + }) } override fun userLoginEvent(method: String) { - logEvent( - Event.USER_LOGIN, - bundleOf( - Key.METHOD.keyName to method - ) - ) + logEvent(Event.USER_LOGIN, buildMap { + put(Key.METHOD.keyName, method) + }) } override fun signUpClickedEvent() { @@ -68,17 +65,13 @@ class AnalyticsManager( } override fun createAccountClickedEvent(provider: String) { - logEvent( - Event.CREATE_ACCOUNT_CLICKED, - bundleOf(Key.PROVIDER.keyName to provider) - ) + logEvent(Event.CREATE_ACCOUNT_CLICKED, buildMap { + put(Key.PROVIDER.keyName, provider) + }) } override fun registrationSuccessEvent(provider: String) { - logEvent( - Event.REGISTRATION_SUCCESS, - bundleOf(Key.PROVIDER.keyName to provider) - ) + logEvent(Event.REGISTRATION_SUCCESS, buildMap { put(Key.PROVIDER.keyName, provider) }) } override fun forgotPasswordClickedEvent() { @@ -86,19 +79,13 @@ class AnalyticsManager( } override fun resetPasswordClickedEvent(success: Boolean) { - logEvent( - Event.RESET_PASSWORD_CLICKED, bundleOf( - Key.SUCCESS.keyName to success - ) - ) + logEvent(Event.RESET_PASSWORD_CLICKED, buildMap { put(Key.SUCCESS.keyName, success) }) } override fun logoutEvent(force: Boolean) { - logEvent( - Event.USER_LOGOUT, bundleOf( - Key.FORCE.keyName to force - ) - ) + logEvent(Event.USER_LOGOUT, buildMap { + put(Key.FORCE.keyName, force) + }) } override fun discoveryTabClickedEvent() { @@ -126,21 +113,17 @@ class AnalyticsManager( } override fun discoveryCourseSearchEvent(label: String, coursesCount: Int) { - logEvent( - Event.DISCOVERY_COURSE_SEARCH, bundleOf( - Key.LABEL.keyName to label, - Key.COURSE_COUNT.keyName to coursesCount - ) - ) + logEvent(Event.DISCOVERY_COURSE_SEARCH, buildMap { + put(Key.LABEL.keyName, label) + put(Key.COURSE_COUNT.keyName, coursesCount) + }) } override fun discoveryCourseClickedEvent(courseId: String, courseName: String) { - logEvent( - Event.DISCOVERY_COURSE_CLICKED, bundleOf( - Key.COURSE_ID.keyName to courseId, - Key.COURSE_NAME.keyName to courseName - ) - ) + logEvent(Event.DISCOVERY_COURSE_CLICKED, buildMap { + put(Key.COURSE_ID.keyName, courseId) + put(Key.COURSE_NAME.keyName, courseName) + }) } override fun profileEditClickedEvent() { @@ -184,226 +167,166 @@ class AnalyticsManager( } override fun courseEnrollClickedEvent(courseId: String, courseName: String) { - logEvent( - Event.COURSE_ENROLL_CLICKED, bundleOf( - Key.COURSE_ID.keyName to courseId, - Key.COURSE_NAME.keyName to courseName, - ) - ) + logEvent(Event.COURSE_ENROLL_CLICKED, buildMap { + put(Key.COURSE_ID.keyName, courseId) + put(Key.COURSE_NAME.keyName, courseName) + }) } override fun courseEnrollSuccessEvent(courseId: String, courseName: String) { - logEvent( - Event.COURSE_ENROLL_SUCCESS, bundleOf( - Key.COURSE_ID.keyName to courseId, - Key.COURSE_NAME.keyName to courseName, - ) - ) + logEvent(Event.COURSE_ENROLL_SUCCESS, buildMap { + put(Key.COURSE_ID.keyName, courseId) + put(Key.COURSE_NAME.keyName, courseName) + }) } override fun viewCourseClickedEvent(courseId: String, courseName: String) { - logEvent( - Event.VIEW_COURSE_CLICKED, bundleOf( - Key.COURSE_ID.keyName to courseId, - Key.COURSE_NAME.keyName to courseName, - ) - ) + logEvent(Event.VIEW_COURSE_CLICKED, buildMap { + put(Key.COURSE_ID.keyName, courseId) + put(Key.COURSE_NAME.keyName, courseName) + }) } override fun resumeCourseTappedEvent(courseId: String, courseName: String, blockId: String) { - logEvent( - Event.RESUME_COURSE_TAPPED, bundleOf( - Key.COURSE_ID.keyName to courseId, - Key.COURSE_NAME.keyName to courseName, - Key.BLOCK_ID.keyName to blockId - ) - ) + logEvent(Event.RESUME_COURSE_TAPPED, buildMap { + put(Key.COURSE_ID.keyName, courseId) + put(Key.COURSE_NAME.keyName, courseName) + put(Key.BLOCK_ID.keyName, blockId) + }) } override fun sequentialClickedEvent( - courseId: String, - courseName: String, - blockId: String, - blockName: String + courseId: String, courseName: String, blockId: String, blockName: String ) { - logEvent( - Event.SEQUENTIAL_CLICKED, bundleOf( - Key.COURSE_ID.keyName to courseId, - Key.COURSE_NAME.keyName to courseName, - Key.BLOCK_ID.keyName to blockId, - Key.BLOCK_NAME.keyName to blockName, - ) - ) + logEvent(Event.SEQUENTIAL_CLICKED, buildMap { + put(Key.COURSE_ID.keyName, courseId) + put(Key.COURSE_NAME.keyName, courseName) + put(Key.BLOCK_ID.keyName, blockId) + put(Key.BLOCK_NAME.keyName, blockName) + }) } override fun verticalClickedEvent( - courseId: String, - courseName: String, - blockId: String, - blockName: String + courseId: String, courseName: String, blockId: String, blockName: String ) { - logEvent( - Event.VERTICAL_CLICKED, bundleOf( - Key.COURSE_ID.keyName to courseId, - Key.COURSE_NAME.keyName to courseName, - Key.BLOCK_ID.keyName to blockId, - Key.BLOCK_NAME.keyName to blockName, - ) - ) + logEvent(Event.VERTICAL_CLICKED, buildMap { + put(Key.COURSE_ID.keyName, courseId) + put(Key.COURSE_NAME.keyName, courseName) + put(Key.BLOCK_ID.keyName, blockId) + put(Key.BLOCK_NAME.keyName, blockName) + }) } override fun nextBlockClickedEvent( - courseId: String, - courseName: String, - blockId: String, - blockName: String + courseId: String, courseName: String, blockId: String, blockName: String ) { - logEvent( - Event.NEXT_BLOCK_CLICKED, bundleOf( - Key.COURSE_ID.keyName to courseId, - Key.COURSE_NAME.keyName to courseName, - Key.BLOCK_ID.keyName to blockId, - Key.BLOCK_NAME.keyName to blockName, - ) - ) + logEvent(Event.NEXT_BLOCK_CLICKED, buildMap { + put(Key.COURSE_ID.keyName, courseId) + put(Key.COURSE_NAME.keyName, courseName) + put(Key.BLOCK_ID.keyName, blockId) + put(Key.BLOCK_NAME.keyName, blockName) + }) } override fun prevBlockClickedEvent( - courseId: String, - courseName: String, - blockId: String, - blockName: String + courseId: String, courseName: String, blockId: String, blockName: String ) { - logEvent( - Event.PREV_BLOCK_CLICKED, bundleOf( - Key.COURSE_ID.keyName to courseId, - Key.COURSE_NAME.keyName to courseName, - Key.BLOCK_ID.keyName to blockId, - Key.BLOCK_NAME.keyName to blockName, - ) - ) + logEvent(Event.PREV_BLOCK_CLICKED, buildMap { + put(Key.COURSE_ID.keyName, courseId) + put(Key.COURSE_NAME.keyName, courseName) + put(Key.BLOCK_ID.keyName, blockId) + put(Key.BLOCK_NAME.keyName, blockName) + }) } override fun finishVerticalClickedEvent( - courseId: String, - courseName: String, - blockId: String, - blockName: String + courseId: String, courseName: String, blockId: String, blockName: String ) { - logEvent( - Event.FINISH_VERTICAL_CLICKED, bundleOf( - Key.COURSE_ID.keyName to courseId, - Key.COURSE_NAME.keyName to courseName, - Key.BLOCK_ID.keyName to blockId, - Key.BLOCK_NAME.keyName to blockName, - ) - ) + logEvent(Event.FINISH_VERTICAL_CLICKED, buildMap { + put(Key.COURSE_ID.keyName, courseId) + put(Key.COURSE_NAME.keyName, courseName) + put(Key.BLOCK_ID.keyName, blockId) + put(Key.BLOCK_NAME.keyName, blockName) + }) } override fun finishVerticalNextClickedEvent( - courseId: String, - courseName: String, - blockId: String, - blockName: String + courseId: String, courseName: String, blockId: String, blockName: String ) { - logEvent( - Event.FINISH_VERTICAL_NEXT_CLICKED, bundleOf( - Key.COURSE_ID.keyName to courseId, - Key.COURSE_NAME.keyName to courseName, - Key.BLOCK_ID.keyName to blockId, - Key.BLOCK_NAME.keyName to blockName, - ) - ) + logEvent(Event.FINISH_VERTICAL_NEXT_CLICKED, buildMap { + put(Key.COURSE_ID.keyName, courseId) + put(Key.COURSE_NAME.keyName, courseName) + put(Key.BLOCK_ID.keyName, blockId) + put(Key.BLOCK_NAME.keyName, blockName) + }) } override fun finishVerticalBackClickedEvent(courseId: String, courseName: String) { - logEvent( - Event.FINISH_VERTICAL_BACK_CLICKED, bundleOf( - Key.COURSE_ID.keyName to courseId, - Key.COURSE_NAME.keyName to courseName - ) - ) + logEvent(Event.FINISH_VERTICAL_BACK_CLICKED, buildMap { + put(Key.COURSE_ID.keyName, courseId) + put(Key.COURSE_NAME.keyName, courseName) + }) } override fun courseTabClickedEvent(courseId: String, courseName: String) { - logEvent( - Event.COURSE_TAB_CLICKED, bundleOf( - Key.COURSE_ID.keyName to courseId, - Key.COURSE_NAME.keyName to courseName - ) - ) + logEvent(Event.COURSE_TAB_CLICKED, buildMap { + put(Key.COURSE_ID.keyName, courseId) + put(Key.COURSE_NAME.keyName, courseName) + }) } override fun videoTabClickedEvent(courseId: String, courseName: String) { - logEvent( - Event.VIDEO_TAB_CLICKED, bundleOf( - Key.COURSE_ID.keyName to courseId, - Key.COURSE_NAME.keyName to courseName - ) - ) + logEvent(Event.VIDEO_TAB_CLICKED, buildMap { + put(Key.COURSE_ID.keyName, courseId) + put(Key.COURSE_NAME.keyName, courseName) + }) } override fun discussionTabClickedEvent(courseId: String, courseName: String) { - logEvent( - Event.DISCUSSION_TAB_CLICKED, bundleOf( - Key.COURSE_ID.keyName to courseId, - Key.COURSE_NAME.keyName to courseName - ) - ) + logEvent(Event.DISCUSSION_TAB_CLICKED, buildMap { + put(Key.COURSE_ID.keyName, courseId) + put(Key.COURSE_NAME.keyName, courseName) + }) } 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 - ) - ) + logEvent(Event.DATES_TAB_CLICKED, buildMap { + put(Key.COURSE_ID.keyName, courseId) + put(Key.COURSE_NAME.keyName, courseName) + }) } override fun handoutsTabClickedEvent(courseId: String, courseName: String) { - logEvent( - Event.HANDOUTS_TAB_CLICKED, bundleOf( - Key.COURSE_ID.keyName to courseId, - Key.COURSE_NAME.keyName to courseName - ) - ) + logEvent(Event.HANDOUTS_TAB_CLICKED, buildMap { + put(Key.COURSE_ID.keyName, courseId) + put(Key.COURSE_NAME.keyName, courseName) + }) } override fun discussionAllPostsClickedEvent(courseId: String, courseName: String) { - logEvent( - Event.DISCUSSION_ALL_POSTS_CLICKED, bundleOf( - Key.COURSE_ID.keyName to courseId, - Key.COURSE_NAME.keyName to courseName - ) - ) + logEvent(Event.DISCUSSION_ALL_POSTS_CLICKED, buildMap { + put(Key.COURSE_ID.keyName, courseId) + put(Key.COURSE_NAME.keyName, courseName) + }) } override fun discussionFollowingClickedEvent(courseId: String, courseName: String) { - logEvent( - Event.DISCUSSION_FOLLOWING_CLICKED, bundleOf( - Key.COURSE_ID.keyName to courseId, - Key.COURSE_NAME.keyName to courseName - ) - ) + logEvent(Event.DISCUSSION_FOLLOWING_CLICKED, buildMap { + put(Key.COURSE_ID.keyName, courseId) + put(Key.COURSE_NAME.keyName, courseName) + }) } override fun discussionTopicClickedEvent( - courseId: String, - courseName: String, - topicId: String, - topicName: String + courseId: String, courseName: String, topicId: String, topicName: String ) { - logEvent( - Event.DISCUSSION_TOPIC_CLICKED, bundleOf( - Key.COURSE_ID.keyName to courseId, - Key.COURSE_NAME.keyName to courseName, - Key.TOPIC_ID.keyName to topicId, - Key.TOPIC_NAME.keyName to topicName - ) - ) + logEvent(Event.DISCUSSION_TOPIC_CLICKED, buildMap { + put(Key.COURSE_ID.keyName, courseId) + put(Key.COURSE_NAME.keyName, courseName) + put(Key.TOPIC_ID.keyName, topicId) + put(Key.TOPIC_NAME.keyName, topicName) + }) } - } private enum class Event(val eventName: String) { diff --git a/app/src/main/java/org/openedx/app/analytics/Analytics.kt b/app/src/main/java/org/openedx/app/analytics/Analytics.kt index ed34ec41a..01ac01860 100644 --- a/app/src/main/java/org/openedx/app/analytics/Analytics.kt +++ b/app/src/main/java/org/openedx/app/analytics/Analytics.kt @@ -1,9 +1,7 @@ package org.openedx.app.analytics -import android.os.Bundle - interface Analytics { - fun logScreenEvent(screenName: String, bundle: Bundle) - fun logEvent(eventName: String, bundle: Bundle) + fun logScreenEvent(screenName: String, params: Map) + fun logEvent(eventName: String, params: Map) 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 index 6e4db40a0..503f3d1ef 100644 --- a/app/src/main/java/org/openedx/app/analytics/FirebaseAnalytics.kt +++ b/app/src/main/java/org/openedx/app/analytics/FirebaseAnalytics.kt @@ -1,30 +1,35 @@ package org.openedx.app.analytics import android.content.Context -import android.os.Bundle -import android.util.Log import com.google.firebase.analytics.FirebaseAnalytics +import org.openedx.core.extension.toBundle +import org.openedx.core.utils.Logger class FirebaseAnalytics(context: Context) : Analytics { - private var tracker: FirebaseAnalytics? = null + private val logger = Logger(TAG) + private var tracker: FirebaseAnalytics init { tracker = FirebaseAnalytics.getInstance(context) - Log.d("Analytics", "Firebase Builder Initialised") + logger.d { "Firebase Analytics Builder Initialised" } } - override fun logScreenEvent(screenName: String, bundle: Bundle) { - Log.d("Analytics", "Firebase log Screen Event: $screenName + $bundle") + override fun logScreenEvent(screenName: String, params: Map) { + logger.d { "Firebase Analytics log Screen Event: $screenName + $params" } } - override fun logEvent(eventName: String, bundle: Bundle) { - tracker?.logEvent(eventName, bundle) - Log.d("Analytics", "Firebase log Event $eventName: $bundle") + override fun logEvent(eventName: String, params: Map) { + tracker.logEvent(eventName, params.toBundle()) + logger.d { "Firebase Analytics log Event $eventName: $params" } } override fun logUserId(userId: Long) { - tracker?.setUserId(userId.toString()) - Log.d("Analytics", "Firebase User Id log Event") + tracker.setUserId(userId.toString()) + logger.d { "Firebase Analytics User Id log Event" } + } + + private companion object { + const val TAG = "FirebaseAnalytics" } } diff --git a/app/src/main/java/org/openedx/app/analytics/SegmentAnalytics.kt b/app/src/main/java/org/openedx/app/analytics/SegmentAnalytics.kt new file mode 100644 index 000000000..3a9532a71 --- /dev/null +++ b/app/src/main/java/org/openedx/app/analytics/SegmentAnalytics.kt @@ -0,0 +1,56 @@ +package org.openedx.app.analytics + +import android.content.Context +import com.segment.analytics.kotlin.destinations.braze.BrazeDestination +import com.segment.analytics.kotlin.destinations.firebase.FirebaseDestination +import org.openedx.app.BuildConfig +import org.openedx.core.config.Config +import org.openedx.core.utils.Logger +import com.segment.analytics.kotlin.android.Analytics as SegmentAnalyticsBuilder +import com.segment.analytics.kotlin.core.Analytics as SegmentTracker + +class SegmentAnalytics(context: Context, config: Config) : Analytics { + + private val logger = Logger(TAG) + private var tracker: SegmentTracker + + init { + // Create an analytics client with the given application context and Segment write key. + tracker = SegmentAnalyticsBuilder(config.getSegmentConfig().segmentWriteKey, context) { + // Automatically track Lifecycle events + trackApplicationLifecycleEvents = true + flushAt = 20 + flushInterval = 30 + } + if (config.getFirebaseConfig().isSegmentAnalyticsSource()) { + tracker.add(plugin = FirebaseDestination(context = context)) + } + + if (config.getFirebaseConfig() + .isSegmentAnalyticsSource() && config.getBrazeConfig().isEnabled + ) { + tracker.add(plugin = BrazeDestination(context)) + } + SegmentTracker.debugLogsEnabled = BuildConfig.DEBUG + logger.d { "Segment Analytics Builder Initialised" } + } + + override fun logScreenEvent(screenName: String, params: Map) { + logger.d { "Segment Analytics log Screen Event: $screenName + $params" } + tracker.screen(screenName, params) + } + + override fun logEvent(eventName: String, params: Map) { + logger.d { "Segment Analytics log Event $eventName: $params" } + tracker.track(eventName, params) + } + + override fun logUserId(userId: Long) { + logger.d { "Segment Analytics User Id log Event: $userId" } + tracker.identify(userId.toString()) + } + + private companion object { + const val TAG = "SegmentAnalytics" + } +} diff --git a/core/src/main/java/org/openedx/core/config/AnalyticsSource.kt b/core/src/main/java/org/openedx/core/config/AnalyticsSource.kt new file mode 100644 index 000000000..b3ee82211 --- /dev/null +++ b/core/src/main/java/org/openedx/core/config/AnalyticsSource.kt @@ -0,0 +1,11 @@ +package org.openedx.core.config + +import com.google.gson.annotations.SerializedName + +enum class AnalyticsSource { + @SerializedName("segment") + SEGMENT, + + @SerializedName("none") + NONE, +} diff --git a/core/src/main/java/org/openedx/core/config/BrazeConfig.kt b/core/src/main/java/org/openedx/core/config/BrazeConfig.kt new file mode 100644 index 000000000..62bb2e9be --- /dev/null +++ b/core/src/main/java/org/openedx/core/config/BrazeConfig.kt @@ -0,0 +1,11 @@ +package org.openedx.core.config + +import com.google.gson.annotations.SerializedName + +data class BrazeConfig( + @SerializedName("ENABLED") + val isEnabled: Boolean = false, + + @SerializedName("PUSH_NOTIFICATIONS_ENABLED") + val isPushNotificationsEnabled: Boolean = false +) diff --git a/core/src/main/java/org/openedx/core/config/Config.kt b/core/src/main/java/org/openedx/core/config/Config.kt index 8739c4cfa..cc1f37269 100644 --- a/core/src/main/java/org/openedx/core/config/Config.kt +++ b/core/src/main/java/org/openedx/core/config/Config.kt @@ -57,6 +57,14 @@ class Config(context: Context) { return getObjectOrNewInstance(FIREBASE, FirebaseConfig::class.java) } + fun getSegmentConfig(): SegmentConfig { + return getObjectOrNewInstance(SEGMENT_IO, SegmentConfig::class.java) + } + + fun getBrazeConfig(): BrazeConfig { + return getObjectOrNewInstance(BRAZE, BrazeConfig::class.java) + } + fun getFacebookConfig(): FacebookConfig { return getObjectOrNewInstance(FACEBOOK, FacebookConfig::class.java) } @@ -148,6 +156,8 @@ class Config(context: Context) { 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 SEGMENT_IO = "SEGMENT_IO" + private const val BRAZE = "BRAZE" private const val FACEBOOK = "FACEBOOK" private const val GOOGLE = "GOOGLE" private const val MICROSOFT = "MICROSOFT" diff --git a/core/src/main/java/org/openedx/core/config/FirebaseConfig.kt b/core/src/main/java/org/openedx/core/config/FirebaseConfig.kt index b003c3230..e659ef2ce 100644 --- a/core/src/main/java/org/openedx/core/config/FirebaseConfig.kt +++ b/core/src/main/java/org/openedx/core/config/FirebaseConfig.kt @@ -6,6 +6,9 @@ data class FirebaseConfig( @SerializedName("ENABLED") val enabled: Boolean = false, + @SerializedName("ANALYTICS_SOURCE") + val analyticsSource: AnalyticsSource = AnalyticsSource.NONE, + @SerializedName("PROJECT_ID") val projectId: String = "", @@ -17,4 +20,8 @@ data class FirebaseConfig( @SerializedName("GCM_SENDER_ID") val gcmSenderId: String = "", -) +) { + fun isSegmentAnalyticsSource(): Boolean { + return enabled && analyticsSource == AnalyticsSource.SEGMENT + } +} diff --git a/core/src/main/java/org/openedx/core/config/SegmentConfig.kt b/core/src/main/java/org/openedx/core/config/SegmentConfig.kt new file mode 100644 index 000000000..ffa43e8bc --- /dev/null +++ b/core/src/main/java/org/openedx/core/config/SegmentConfig.kt @@ -0,0 +1,11 @@ +package org.openedx.core.config + +import com.google.gson.annotations.SerializedName + +data class SegmentConfig( + @SerializedName("ENABLED") + val enabled: Boolean = false, + + @SerializedName("SEGMENT_IO_WRITE_KEY") + val segmentWriteKey: String = "", +) diff --git a/core/src/main/java/org/openedx/core/extension/MapExt.kt b/core/src/main/java/org/openedx/core/extension/MapExt.kt new file mode 100644 index 000000000..f985d119d --- /dev/null +++ b/core/src/main/java/org/openedx/core/extension/MapExt.kt @@ -0,0 +1,13 @@ +package org.openedx.core.extension + +import android.os.Bundle + +fun Map.toBundle(): Bundle { + val bundle = Bundle() + for ((key, value) in this.entries) { + value?.let { + bundle.putString(key, it.toString()) + } + } + return bundle +} diff --git a/default_config/dev/config.yaml b/default_config/dev/config.yaml index f074ddd9d..7de39893a 100644 --- a/default_config/dev/config.yaml +++ b/default_config/dev/config.yaml @@ -28,6 +28,23 @@ PROGRAM: PROGRAM_URL: '' PROGRAM_DETAIL_URL_TEMPLATE: '' +FIREBASE: + ENABLED: false + ANALYTICS_SOURCE: '' # segment | none + CLOUD_MESSAGING_ENABLED: false + PROJECT_ID: "" + APPLICATION_ID: "" + API_KEY: "" + GCM_SENDER_ID: "" + +SEGMENT_IO: + ENABLED: false + SEGMENT_IO_WRITE_KEY: '' + +BRAZE: + ENABLED: false + PUSH_NOTIFICATIONS_ENABLED: false + GOOGLE: ENABLED: false CLIENT_ID: '' diff --git a/default_config/prod/config.yaml b/default_config/prod/config.yaml index 25df957a0..ef70aeae7 100644 --- a/default_config/prod/config.yaml +++ b/default_config/prod/config.yaml @@ -28,6 +28,23 @@ PROGRAM: PROGRAM_URL: '' PROGRAM_DETAIL_URL_TEMPLATE: '' +FIREBASE: + ENABLED: false + ANALYTICS_SOURCE: '' # segment | none + CLOUD_MESSAGING_ENABLED: false + PROJECT_ID: "" + APPLICATION_ID: "" + API_KEY: "" + GCM_SENDER_ID: "" + +SEGMENT_IO: + ENABLED: false + SEGMENT_IO_WRITE_KEY: '' + +BRAZE: + ENABLED: false + PUSH_NOTIFICATIONS_ENABLED: false + GOOGLE: ENABLED: false CLIENT_ID: '' diff --git a/default_config/stage/config.yaml b/default_config/stage/config.yaml index 25df957a0..ef70aeae7 100644 --- a/default_config/stage/config.yaml +++ b/default_config/stage/config.yaml @@ -28,6 +28,23 @@ PROGRAM: PROGRAM_URL: '' PROGRAM_DETAIL_URL_TEMPLATE: '' +FIREBASE: + ENABLED: false + ANALYTICS_SOURCE: '' # segment | none + CLOUD_MESSAGING_ENABLED: false + PROJECT_ID: "" + APPLICATION_ID: "" + API_KEY: "" + GCM_SENDER_ID: "" + +SEGMENT_IO: + ENABLED: false + SEGMENT_IO_WRITE_KEY: '' + +BRAZE: + ENABLED: false + PUSH_NOTIFICATIONS_ENABLED: false + GOOGLE: ENABLED: false CLIENT_ID: '' From dedb010f4f7dde41cecc320f395fd601476daacb Mon Sep 17 00:00:00 2001 From: Omer Habib <30689349+omerhabib26@users.noreply.github.com> Date: Thu, 15 Feb 2024 11:59:52 +0500 Subject: [PATCH 05/39] fix: Added element IDs for automation (#214) - Added Element IDs for automation to Views --- .../org/openedx/app/InDevelopmentFragment.kt | 14 +- .../openedx/auth/presentation/ui/AuthUI.kt | 19 +- .../core/domain/model/VideoSettings.kt | 37 ++- .../org/openedx/core/extension/StringExt.kt | 5 + .../global/app_upgrade/AppUpdateUI.kt | 27 +- .../java/org/openedx/core/ui/ComposeCommon.kt | 134 +++++++-- .../org/openedx/core/ui/WebContentScreen.kt | 31 +- core/src/main/res/values-uk/strings.xml | 8 +- core/src/main/res/values/strings.xml | 11 +- .../detail/CourseDetailsFragment.kt | 52 ++-- .../course/presentation/ui/CourseUI.kt | 4 + .../dashboard/DashboardFragment.kt | 106 +++++-- .../presentation/program/ProgramFragment.kt | 8 +- .../presentation/NativeDiscoveryFragment.kt | 18 +- .../presentation/WebViewDiscoveryFragment.kt | 10 +- .../search/CourseSearchFragment.kt | 12 +- .../delete/DeleteProfileFragment.kt | 44 ++- .../presentation/edit/EditProfileFragment.kt | 276 +++++++++++------- .../profile/compose/ProfileView.kt | 50 +++- .../settings/video/VideoQualityFragment.kt | 138 ++++----- .../settings/video/VideoSettingsFragment.kt | 81 +++-- .../profile/presentation/ui/ProfileUI.kt | 35 ++- 22 files changed, 750 insertions(+), 370 deletions(-) diff --git a/app/src/main/java/org/openedx/app/InDevelopmentFragment.kt b/app/src/main/java/org/openedx/app/InDevelopmentFragment.kt index d99fef53b..d8ca717d4 100644 --- a/app/src/main/java/org/openedx/app/InDevelopmentFragment.kt +++ b/app/src/main/java/org/openedx/app/InDevelopmentFragment.kt @@ -11,15 +11,20 @@ import androidx.compose.material.MaterialTheme import androidx.compose.material.Scaffold import androidx.compose.material.Text import androidx.compose.ui.Alignment +import androidx.compose.ui.ExperimentalComposeUiApi import androidx.compose.ui.Modifier import androidx.compose.ui.platform.ComposeView import androidx.compose.ui.platform.ViewCompositionStrategy +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.semantics.testTagsAsResourceId import androidx.fragment.app.Fragment import org.openedx.core.ui.theme.appColors import org.openedx.core.ui.theme.appTypography class InDevelopmentFragment : Fragment() { + @OptIn(ExperimentalComposeUiApi::class) override fun onCreateView( inflater: LayoutInflater, container: ViewGroup?, @@ -27,7 +32,11 @@ class InDevelopmentFragment : Fragment() { ) = ComposeView(requireContext()).apply { setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed) setContent { - Scaffold { + Scaffold( + modifier = Modifier.semantics { + testTagsAsResourceId = true + }, + ) { Box( modifier = Modifier .fillMaxSize() @@ -36,6 +45,7 @@ class InDevelopmentFragment : Fragment() { contentAlignment = Alignment.Center ) { Text( + modifier = Modifier.testTag("txt_in_development"), text = "Will be available soon", style = MaterialTheme.appTypography.headlineMedium ) @@ -43,4 +53,4 @@ class InDevelopmentFragment : Fragment() { } } } -} \ No newline at end of file +} 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 e875a4539..a16e77505 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 @@ -50,6 +50,7 @@ import org.openedx.auth.R import org.openedx.core.domain.model.RegistrationField import org.openedx.core.domain.model.RegistrationFieldType import org.openedx.core.extension.TextConverter +import org.openedx.core.extension.tagId import org.openedx.core.ui.HyperlinkText import org.openedx.core.ui.SheetContent import org.openedx.core.ui.noRippleClickable @@ -86,7 +87,7 @@ fun RequiredFields( val linkedText = TextConverter.htmlTextToLinkedText(field.label) HyperlinkText( - modifier = Modifier.testTag("txt_${field.name}"), + modifier = Modifier.testTag("txt_${field.name.tagId()}"), fullText = linkedText.text, hyperLinks = linkedText.links, linkTextColor = MaterialTheme.appColors.primary @@ -305,7 +306,7 @@ fun InputRegistrationField( Column { Text( modifier = Modifier - .testTag("txt_${registrationField.name}_label") + .testTag("txt_${registrationField.name.tagId()}_label") .fillMaxWidth(), text = registrationField.label, style = MaterialTheme.appTypography.labelLarge, @@ -329,7 +330,7 @@ fun InputRegistrationField( shape = MaterialTheme.appShapes.textFieldShape, placeholder = { Text( - modifier = modifier.testTag("txt_${registrationField.name}_placeholder"), + modifier = modifier.testTag("txt_${registrationField.name.tagId()}_placeholder"), text = registrationField.label, color = MaterialTheme.appColors.textFieldHint, style = MaterialTheme.appTypography.bodyMedium @@ -345,11 +346,11 @@ fun InputRegistrationField( }, textStyle = MaterialTheme.appTypography.bodyMedium, singleLine = isSingleLine, - modifier = modifier.testTag("tf_${registrationField.name}") + modifier = modifier.testTag("tf_${registrationField.name.tagId()}") ) Spacer(modifier = Modifier.height(6.dp)) Text( - modifier = Modifier.testTag("txt_${registrationField.name}_description"), + modifier = Modifier.testTag("txt_${registrationField.name.tagId()}_description"), text = helperText, style = MaterialTheme.appTypography.bodySmall, color = helperTextColor @@ -392,7 +393,7 @@ fun SelectableRegisterField( ) { Text( modifier = Modifier - .testTag("txt_${registrationField.name}_label") + .testTag("txt_${registrationField.name.tagId()}_label") .fillMaxWidth(), text = registrationField.label, style = MaterialTheme.appTypography.labelLarge, @@ -414,14 +415,14 @@ fun SelectableRegisterField( textStyle = MaterialTheme.appTypography.bodyMedium, onValueChange = { }, modifier = Modifier - .testTag("tf_${registrationField.name}") + .testTag("tf_${registrationField.name.tagId()}") .fillMaxWidth() .noRippleClickable { onClick(registrationField.name, registrationField.options) }, placeholder = { Text( - modifier = Modifier.testTag("txt_${registrationField.name}_placeholder"), + modifier = Modifier.testTag("txt_${registrationField.name.tagId()}_placeholder"), text = registrationField.label, color = MaterialTheme.appColors.textFieldHint, style = MaterialTheme.appTypography.bodyMedium @@ -437,7 +438,7 @@ fun SelectableRegisterField( ) Spacer(modifier = Modifier.height(6.dp)) Text( - modifier = Modifier.testTag("txt_${registrationField.name}_description"), + modifier = Modifier.testTag("txt_${registrationField.name.tagId()}_description"), text = helperText, style = MaterialTheme.appTypography.bodySmall, color = helperTextColor 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 07241824b..eb9d6309b 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,34 @@ data class VideoSettings( } } -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() +enum class VideoQuality( + val titleResId: Int, + val desResId: Int = 0, + val width: Int, + val height: Int +) { + AUTO( + titleResId = R.string.core_video_quality_auto, + desResId = R.string.core_video_quality_auto_description, + width = 0, + height = 0 + ), + OPTION_360P( + titleResId = R.string.core_video_quality_p360, + desResId = R.string.core_video_quality_p360_description, + width = 640, + height = 360 + ), + OPTION_540P( + titleResId = R.string.core_video_quality_p540, + desResId = 0, + width = 960, + height = 540 + ), + OPTION_720P( + titleResId = R.string.core_video_quality_p720, + desResId = R.string.core_video_quality_p720_description, + width = 1280, + height = 720 + ); } diff --git a/core/src/main/java/org/openedx/core/extension/StringExt.kt b/core/src/main/java/org/openedx/core/extension/StringExt.kt index 0d7281320..58a8eef26 100644 --- a/core/src/main/java/org/openedx/core/extension/StringExt.kt +++ b/core/src/main/java/org/openedx/core/extension/StringExt.kt @@ -1,6 +1,7 @@ package org.openedx.core.extension import android.util.Patterns +import java.util.Locale import java.util.regex.Pattern @@ -28,3 +29,7 @@ fun String.replaceLinkTags(isDarkTheme: Boolean): String { } return text } + +fun String.replaceSpace(target: String = ""): String = this.replace(" ", target) + +fun String.tagId(): String = this.replaceSpace("_").lowercase(Locale.getDefault()) 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 index cfa53da7c..f0502b49d 100644 --- 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 @@ -25,12 +25,16 @@ import androidx.compose.material.Surface import androidx.compose.material.Text import androidx.compose.runtime.Composable 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.graphics.Color import androidx.compose.ui.platform.LocalConfiguration +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.Preview import androidx.compose.ui.unit.dp @@ -55,6 +59,7 @@ fun AppUpgradeRequiredScreen( ) } +@OptIn(ExperimentalComposeUiApi::class) @Composable fun AppUpgradeRequiredScreen( modifier: Modifier = Modifier, @@ -66,11 +71,13 @@ fun AppUpgradeRequiredScreen( modifier = modifier .fillMaxSize() .background(color = MaterialTheme.appColors.background) - .statusBarsInset(), + .statusBarsInset() + .semantics { testTagsAsResourceId = true }, contentAlignment = Alignment.TopCenter ) { Text( modifier = Modifier + .testTag("txt_app_upgrade_deprecated") .fillMaxWidth() .padding(top = 10.dp, bottom = 12.dp), text = stringResource(id = R.string.core_deprecated_app_version), @@ -92,6 +99,7 @@ fun AppUpgradeRequiredScreen( } } +@OptIn(ExperimentalComposeUiApi::class) @Composable fun AppUpgradeRecommendDialog( modifier: Modifier = Modifier, @@ -106,11 +114,12 @@ fun AppUpgradeRecommendDialog( } Surface( - modifier = modifier, + modifier = modifier.semantics { testTagsAsResourceId = true }, color = Color.Transparent ) { Box( modifier = modifier + .testTag("btn_upgrade_dialog_not_now") .fillMaxSize() .padding(horizontal = 4.dp) .noRippleClickable { @@ -142,11 +151,13 @@ fun AppUpgradeRecommendDialog( contentDescription = null ) Text( + modifier = Modifier.testTag("txt_app_upgrade_title"), text = stringResource(id = R.string.core_app_upgrade_title), color = MaterialTheme.appColors.textPrimary, style = MaterialTheme.appTypography.titleMedium ) Text( + modifier = Modifier.testTag("txt_app_upgrade_description"), text = stringResource(id = R.string.core_app_upgrade_dialog_description), color = MaterialTheme.appColors.textPrimary, textAlign = TextAlign.Center, @@ -162,6 +173,7 @@ fun AppUpgradeRecommendDialog( } } +@OptIn(ExperimentalComposeUiApi::class) @Composable fun AppUpgradeRequiredContent( modifier: Modifier = Modifier, @@ -170,7 +182,7 @@ fun AppUpgradeRequiredContent( onUpdateClick: () -> Unit ) { Column( - modifier = modifier, + modifier = modifier.semantics { testTagsAsResourceId = true }, horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(32.dp) ) { @@ -183,11 +195,13 @@ fun AppUpgradeRequiredContent( verticalArrangement = Arrangement.spacedBy(12.dp) ) { Text( + modifier = Modifier.testTag("txt_app_upgrade_required_title"), text = stringResource(id = R.string.core_app_update_required_title), color = MaterialTheme.appColors.textPrimary, style = MaterialTheme.appTypography.titleMedium ) Text( + modifier = Modifier.testTag("txt_app_upgrade_required_description"), text = stringResource(id = R.string.core_app_update_required_description), color = MaterialTheme.appColors.textPrimary, textAlign = TextAlign.Center, @@ -250,6 +264,7 @@ fun TransparentTextButton( ) { Button( modifier = Modifier + .testTag("btn_secondary") .height(42.dp), colors = ButtonDefaults.buttonColors( backgroundColor = Color.Transparent @@ -259,6 +274,7 @@ fun TransparentTextButton( onClick = onClick ) { Text( + modifier = Modifier.testTag("txt_secondary"), color = MaterialTheme.appColors.textAccent, style = MaterialTheme.appTypography.labelLarge, text = text @@ -273,6 +289,7 @@ fun DefaultTextButton( ) { Button( modifier = Modifier + .testTag("btn_primary") .height(42.dp), colors = ButtonDefaults.buttonColors( backgroundColor = MaterialTheme.appColors.buttonBackground @@ -286,6 +303,7 @@ fun DefaultTextButton( horizontalArrangement = Arrangement.Center ) { Text( + modifier = Modifier.testTag("txt_primary"), text = text, color = MaterialTheme.appColors.buttonText, style = MaterialTheme.appTypography.labelLarge @@ -301,6 +319,7 @@ fun AppUpgradeRecommendedBox( ) { Card( modifier = modifier + .testTag("btn_upgrade_box") .fillMaxWidth() .padding(20.dp) .clickable { @@ -322,11 +341,13 @@ fun AppUpgradeRecommendedBox( ) Column { Text( + modifier = Modifier.testTag("txt_app_upgrade_title"), text = stringResource(id = R.string.core_app_upgrade_title), color = Color.White, style = MaterialTheme.appTypography.titleMedium ) Text( + modifier = Modifier.testTag("txt_app_upgrade_description"), text = stringResource(id = R.string.core_app_upgrade_box_description), color = Color.White, style = MaterialTheme.appTypography.bodyMedium 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 b5b4dca7c..8ccd75637 100644 --- a/core/src/main/java/org/openedx/core/ui/ComposeCommon.kt +++ b/core/src/main/java/org/openedx/core/ui/ComposeCommon.kt @@ -96,7 +96,9 @@ 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.tagId import org.openedx.core.extension.toastMessage +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 @@ -108,19 +110,21 @@ fun StaticSearchBar( onClick: () -> Unit = {}, ) { Row( - modifier = modifier.then(Modifier - .background( - MaterialTheme.appColors.textFieldBackground, - MaterialTheme.appShapes.textFieldShape - ) - .clip(MaterialTheme.appShapes.textFieldShape) - .border( - 1.dp, - MaterialTheme.appColors.textFieldBorder, - MaterialTheme.appShapes.textFieldShape - ) - .clickable { onClick() } - .padding(horizontal = 20.dp)), + modifier = modifier + .testTag("tf_search") + .then(Modifier + .background( + MaterialTheme.appColors.textFieldBackground, + MaterialTheme.appShapes.textFieldShape + ) + .clip(MaterialTheme.appShapes.textFieldShape) + .border( + 1.dp, + MaterialTheme.appColors.textFieldBorder, + MaterialTheme.appShapes.textFieldShape + ) + .clickable { onClick() } + .padding(horizontal = 20.dp)), verticalAlignment = Alignment.CenterVertically ) { Icon( @@ -131,7 +135,9 @@ fun StaticSearchBar( Spacer(Modifier.width(10.dp)) Box { Text( - modifier = Modifier.fillMaxWidth(), + modifier = Modifier + .testTag("txt_search") + .fillMaxWidth(), text = text, color = MaterialTheme.appColors.textFieldHint ) @@ -157,6 +163,7 @@ fun Toolbar( Text( modifier = Modifier + .testTag("txt_toolbar_title") .align(Alignment.Center), text = label, color = MaterialTheme.appColors.textPrimary, @@ -196,6 +203,7 @@ fun SearchBar( } OutlinedTextField( modifier = Modifier + .testTag("tf_search") .focusRequester(focusRequester) .onFocusChanged { isFocused = it.hasFocus @@ -617,7 +625,7 @@ fun SheetContent( }) { item -> Text( modifier = Modifier - .testTag("txt_${item.value}_title") + .testTag("txt_${item.value.tagId()}_title") .fillMaxWidth() .padding(horizontal = 16.dp) .clickable { @@ -717,7 +725,9 @@ fun OpenEdXOutlinedTextField( Column { Text( - modifier = Modifier.fillMaxWidth(), + modifier = Modifier + .testTag("txt_${title.tagId()}_label") + .fillMaxWidth(), text = buildAnnotatedString { if (withRequiredMark) { append(title) @@ -763,11 +773,12 @@ fun OpenEdXOutlinedTextField( textStyle = MaterialTheme.appTypography.bodyMedium, singleLine = isSingleLine, isError = !errorText.isNullOrEmpty(), - modifier = modifier + modifier = modifier.testTag("tf_${title.tagId()}_input") ) if (!errorText.isNullOrEmpty()) { Spacer(modifier = Modifier.height(6.dp)) Text( + modifier = Modifier.testTag("txt_${title.tagId()}_error"), text = errorText, style = MaterialTheme.appTypography.bodySmall, color = MaterialTheme.appColors.error @@ -829,6 +840,7 @@ fun DiscoveryCourseItem( val imageUrl = apiHostUrl.dropLast(1) + course.media.courseImage?.uri Surface( modifier = Modifier + .testTag("btn_course_card") .fillMaxWidth() .height(140.dp) .clickable { onClick(course.courseId) } @@ -860,12 +872,15 @@ fun DiscoveryCourseItem( .height(105.dp), ) { Text( - modifier = Modifier.padding(top = 12.dp), + modifier = Modifier + .testTag("txt_course_org") + .padding(top = 12.dp), text = course.org, color = MaterialTheme.appColors.textFieldHint, style = MaterialTheme.appTypography.labelMedium ) Text( modifier = Modifier + .testTag("txt_course_title") .fillMaxWidth() .padding(top = 8.dp), text = course.name, @@ -891,7 +906,9 @@ fun IconText( val modifierClickable = if (onClick == null) { Modifier } else { - Modifier.noRippleClickable { onClick.invoke() } + Modifier + .testTag("btn_${text.tagId()}") + .noRippleClickable { onClick.invoke() } } Row( modifier = modifier.then(modifierClickable), @@ -899,12 +916,19 @@ fun IconText( horizontalArrangement = Arrangement.spacedBy(4.dp) ) { Icon( - modifier = Modifier.size((textStyle.fontSize.value + 4).dp), + modifier = Modifier + .testTag("ic_${text.tagId()}") + .size((textStyle.fontSize.value + 4).dp), imageVector = icon, contentDescription = null, tint = color ) - Text(text = text, color = color, style = textStyle) + Text( + modifier = Modifier.testTag("txt_${text.tagId()}"), + text = text, + color = color, + style = textStyle + ) } } @@ -920,7 +944,9 @@ fun IconText( val modifierClickable = if (onClick == null) { Modifier } else { - Modifier.noRippleClickable { onClick.invoke() } + Modifier + .testTag("btn_${text.tagId()}") + .noRippleClickable { onClick.invoke() } } Row( modifier = modifier.then(modifierClickable), @@ -928,12 +954,19 @@ fun IconText( horizontalArrangement = Arrangement.spacedBy(4.dp) ) { Icon( - modifier = Modifier.size((textStyle.fontSize.value + 4).dp), + modifier = Modifier + .testTag("ic_${text.tagId()}") + .size((textStyle.fontSize.value + 4).dp), painter = painter, contentDescription = null, tint = color ) - Text(text = text, color = color, style = textStyle) + Text( + modifier = Modifier.testTag("txt_${text.tagId()}"), + text = text, + color = color, + style = textStyle + ) } } @@ -1014,19 +1047,24 @@ fun OfflineModeDialog( horizontalArrangement = Arrangement.SpaceBetween ) { Text( + modifier = Modifier.testTag("txt_offline_label"), text = stringResource(id = R.string.core_offline), style = MaterialTheme.appTypography.labelMedium, color = MaterialTheme.appColors.textDark ) Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { Text( - modifier = Modifier.clickable { onDismissCLick() }, + modifier = Modifier + .testTag("txt_dismiss") + .clickable { onDismissCLick() }, text = stringResource(id = R.string.core_dismiss), style = MaterialTheme.appTypography.labelMedium, color = MaterialTheme.appColors.primary ) Text( - modifier = Modifier.clickable { onReloadClick() }, + modifier = Modifier + .testTag("txt_reload") + .clickable { onReloadClick() }, text = stringResource(id = R.string.core_reload), style = MaterialTheme.appTypography.labelMedium, color = MaterialTheme.appColors.primary @@ -1047,6 +1085,7 @@ fun OpenEdXButton( ) { Button( modifier = Modifier + .testTag("btn_${text.tagId()}") .then(width) .height(42.dp), shape = MaterialTheme.appShapes.buttonShape, @@ -1058,6 +1097,7 @@ fun OpenEdXButton( ) { if (content == null) { Text( + modifier = Modifier.testTag("txt_${text.tagId()}"), text = text, color = MaterialTheme.appColors.buttonText, style = MaterialTheme.appTypography.labelLarge @@ -1080,6 +1120,7 @@ fun OpenEdXOutlinedButton( ) { OutlinedButton( modifier = Modifier + .testTag("btn_${text.tagId()}") .then(modifier) .height(42.dp), onClick = onClick, @@ -1089,6 +1130,7 @@ fun OpenEdXOutlinedButton( ) { if (content == null) { Text( + modifier = Modifier.testTag("txt_${text.tagId()}"), text = text, style = MaterialTheme.appTypography.labelLarge, color = textColor @@ -1133,7 +1175,9 @@ fun ConnectionErrorView( ) Spacer(Modifier.height(16.dp)) Text( - modifier = Modifier.fillMaxWidth(0.6f), + modifier = Modifier + .testTag("txt_connection_error_label") + .fillMaxWidth(0.6f), text = stringResource(id = R.string.core_not_connected_to_internet), color = MaterialTheme.appColors.textPrimary, style = MaterialTheme.appTypography.titleMedium, @@ -1219,3 +1263,39 @@ private fun ToolbarPreview() { private fun AuthButtonsPanelPreview() { AuthButtonsPanel(onRegisterClick = {}, onSignInClick = {}) } + +@Preview +@Composable +private fun OpenEdXOutlinedTextFieldPreview() { + OpenEdXTheme(darkTheme = true) { + OpenEdXOutlinedTextField( + modifier = Modifier + .fillMaxWidth(), + title = "OpenEdXOutlinedTextField", + onValueChanged = {}, + keyboardActions = {}, + ) + } +} + +@Preview +@Composable +private fun IconTextPreview() { + IconText( + text = "IconText", + icon = Icons.Filled.Close, + color = MaterialTheme.appColors.primary + ) +} + +@Preview +@Composable +private fun ConnectionErrorViewPreview() { + OpenEdXTheme(darkTheme = true) { + ConnectionErrorView( + modifier = Modifier + .fillMaxSize(), + onReloadClick = {} + ) + } +} diff --git a/core/src/main/java/org/openedx/core/ui/WebContentScreen.kt b/core/src/main/java/org/openedx/core/ui/WebContentScreen.kt index e58879326..a79200111 100644 --- a/core/src/main/java/org/openedx/core/ui/WebContentScreen.kt +++ b/core/src/main/java/org/openedx/core/ui/WebContentScreen.kt @@ -20,7 +20,6 @@ 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 @@ -30,11 +29,12 @@ 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.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.semantics.semantics +import androidx.compose.ui.semantics.testTagsAsResourceId import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import androidx.compose.ui.viewinterop.AndroidView @@ -42,10 +42,10 @@ 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 +@OptIn(ExperimentalComposeUiApi::class) @Composable fun WebContentScreen( windowSize: WindowSize, @@ -59,7 +59,10 @@ fun WebContentScreen( Scaffold( modifier = Modifier .fillMaxSize() - .padding(bottom = 16.dp), + .padding(bottom = 16.dp) + .semantics { + testTagsAsResourceId = true + }, scaffoldState = scaffoldState, backgroundColor = MaterialTheme.appColors.background ) { @@ -88,20 +91,10 @@ fun WebContentScreen( .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 + Toolbar( + label = title, + canShowBackBtn = true, + onBackClick = onBackClick ) } Spacer(Modifier.height(6.dp)) diff --git a/core/src/main/res/values-uk/strings.xml b/core/src/main/res/values-uk/strings.xml index 827211c40..ff7fa8fd6 100644 --- a/core/src/main/res/values-uk/strings.xml +++ b/core/src/main/res/values-uk/strings.xml @@ -20,10 +20,10 @@ Термін дії курсу минув %1$s Пароль незабаром - Авто (Рекомендовано) - 360p (Менше використання трафіку) - 540p - 720p (Найкраща якість) + Авто + Рекомендовано + Менше використання трафіку + Найкраща якість Офлайн Закрити Перезавантажити diff --git a/core/src/main/res/values/strings.xml b/core/src/main/res/values/strings.xml index 6b77b5b11..c493da626 100644 --- a/core/src/main/res/values/strings.xml +++ b/core/src/main/res/values/strings.xml @@ -29,10 +29,13 @@ Dismiss Reload Downloading in progress - Auto (Recommended) - 360p (Lower data usage) - 540p - 720p (Best quality) + Auto + Recommended + 360p + Lower data usage + 540p + 720p translatable="false" + Best quality User account is not activated. Please activate your account first. Send email using… No e-mail clients installed diff --git a/course/src/main/java/org/openedx/course/presentation/detail/CourseDetailsFragment.kt b/course/src/main/java/org/openedx/course/presentation/detail/CourseDetailsFragment.kt index cc7a500f4..8b031fc4f 100644 --- a/course/src/main/java/org/openedx/course/presentation/detail/CourseDetailsFragment.kt +++ b/course/src/main/java/org/openedx/course/presentation/detail/CourseDetailsFragment.kt @@ -19,6 +19,7 @@ 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.draw.alpha import androidx.compose.ui.draw.shadow @@ -26,8 +27,8 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.* 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.semantics.semantics +import androidx.compose.ui.semantics.testTagsAsResourceId import androidx.compose.ui.tooling.preview.Devices import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.Dp @@ -149,6 +150,7 @@ class CourseDetailsFragment : Fragment() { } +@OptIn(ExperimentalComposeUiApi::class) @Composable internal fun CourseDetailsScreen( windowSize: WindowSize, @@ -174,7 +176,10 @@ internal fun CourseDetailsScreen( Scaffold( modifier = Modifier .fillMaxSize() - .navigationBarsPadding(), + .navigationBarsPadding() + .semantics { + testTagsAsResourceId = true + }, scaffoldState = scaffoldState, backgroundColor = MaterialTheme.appColors.background, bottomBar = { @@ -224,27 +229,14 @@ internal fun CourseDetailsScreen( Column( screenWidth ) { - Box( - Modifier + Toolbar( + modifier = Modifier .fillMaxWidth() .zIndex(1f), - contentAlignment = Alignment.CenterStart - ) { - BackBtn { - onBackClick() - } - Text( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 56.dp), - text = stringResource(id = courseR.string.course_details), - color = MaterialTheme.appColors.textPrimary, - style = MaterialTheme.appTypography.titleMedium, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - textAlign = TextAlign.Center - ) - } + label = stringResource(id = courseR.string.course_details), + canShowBackBtn = true, + onBackClick = onBackClick + ) Spacer(Modifier.height(6.dp)) Box( Modifier @@ -286,7 +278,12 @@ internal fun CourseDetailsScreen( ) } if (isPreview) { - Text(htmlBody, Modifier.padding(all = 20.dp)) + Text( + text = htmlBody, + modifier = Modifier + .testTag("txt_course_description") + .padding(all = 20.dp), + ) } else { var webViewAlpha by remember { mutableStateOf(0f) } if (webViewAlpha == 0f) { @@ -384,6 +381,7 @@ private fun CourseDetailNativeContent( ) if (!course.media.courseVideo?.uri.isNullOrEmpty()) { IconButton( + modifier = Modifier.testTag("ib_play_video"), onClick = { uriHandler.openUri(course.media.courseVideo?.uri!!) } @@ -409,18 +407,21 @@ private fun CourseDetailNativeContent( Spacer(Modifier.height(24.dp)) } Text( + modifier = Modifier.testTag("txt_course_short_description"), text = course.shortDescription, style = MaterialTheme.appTypography.labelSmall, color = MaterialTheme.appColors.textPrimaryVariant ) Spacer(Modifier.height(16.dp)) Text( + modifier = Modifier.testTag("txt_course_name"), text = course.name, style = MaterialTheme.appTypography.titleLarge, color = MaterialTheme.appColors.textPrimary ) Spacer(Modifier.height(12.dp)) Text( + modifier = Modifier.testTag("txt_course_org"), text = course.org, style = MaterialTheme.appTypography.labelMedium, color = MaterialTheme.appColors.textAccent @@ -473,18 +474,21 @@ private fun CourseDetailNativeContentLandscape( ) { Column { Text( + modifier = Modifier.testTag("txt_course_short_description"), text = course.shortDescription, style = MaterialTheme.appTypography.labelSmall, color = MaterialTheme.appColors.textPrimaryVariant ) Spacer(Modifier.height(16.dp)) Text( + modifier = Modifier.testTag("txt_course_name"), text = course.name, style = MaterialTheme.appTypography.titleLarge, color = MaterialTheme.appColors.textPrimary ) Spacer(Modifier.height(12.dp)) Text( + modifier = Modifier.testTag("txt_course_org"), text = course.org, style = MaterialTheme.appTypography.labelMedium, color = MaterialTheme.appColors.textAccent @@ -516,6 +520,7 @@ private fun CourseDetailNativeContentLandscape( ) if (!course.media.courseVideo?.uri.isNullOrEmpty()) { IconButton( + modifier = Modifier.testTag("ib_play_video"), onClick = { uriHandler.openUri(course.media.courseVideo?.uri!!) } @@ -572,6 +577,7 @@ private fun EnrollOverLabel() { ) Spacer(Modifier.width(12.dp)) Text( + modifier = Modifier.testTag("txt_enroll_error"), text = stringResource(id = courseR.string.course_you_cant_enroll), color = MaterialTheme.appColors.textPrimaryVariant, style = MaterialTheme.appTypography.titleSmall diff --git a/course/src/main/java/org/openedx/course/presentation/ui/CourseUI.kt b/course/src/main/java/org/openedx/course/presentation/ui/CourseUI.kt index 1e6f81361..3595c9652 100644 --- a/course/src/main/java/org/openedx/course/presentation/ui/CourseUI.kt +++ b/course/src/main/java/org/openedx/course/presentation/ui/CourseUI.kt @@ -62,6 +62,7 @@ import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalConfiguration import androidx.compose.ui.platform.LocalContext 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.style.TextAlign @@ -155,18 +156,21 @@ fun CourseImageHeader( verticalArrangement = Arrangement.Center ) { Icon( + modifier = Modifier.testTag("ic_congratulations"), painter = painterResource(id = R.drawable.ic_course_completed_mark), contentDescription = stringResource(id = R.string.course_congratulations), tint = Color.White ) Spacer(Modifier.height(6.dp)) Text( + modifier = Modifier.testTag("txt_congratulations"), text = stringResource(id = R.string.course_congratulations), style = MaterialTheme.appTypography.headlineMedium, color = Color.White ) Spacer(Modifier.height(4.dp)) Text( + modifier = Modifier.testTag("txt_course_passed"), text = stringResource(id = R.string.course_passed), style = MaterialTheme.appTypography.bodyMedium, color = Color.White diff --git a/dashboard/src/main/java/org/openedx/dashboard/presentation/dashboard/DashboardFragment.kt b/dashboard/src/main/java/org/openedx/dashboard/presentation/dashboard/DashboardFragment.kt index 849423409..0582f663e 100644 --- a/dashboard/src/main/java/org/openedx/dashboard/presentation/dashboard/DashboardFragment.kt +++ b/dashboard/src/main/java/org/openedx/dashboard/presentation/dashboard/DashboardFragment.kt @@ -7,29 +7,58 @@ import android.view.LayoutInflater import android.view.ViewGroup import androidx.compose.foundation.background 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.PaddingValues +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.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.shape.CircleShape -import androidx.compose.material.* +import androidx.compose.material.CircularProgressIndicator +import androidx.compose.material.Divider +import androidx.compose.material.ExperimentalMaterialApi +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.icons.Icons import androidx.compose.material.icons.filled.ArrowForward 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.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.draw.clip import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.ComposeView import androidx.compose.ui.platform.LocalContext 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.text.style.TextOverflow import androidx.compose.ui.tooling.preview.Devices @@ -43,7 +72,11 @@ 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.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.presentation.global.app_upgrade.AppUpgradeRecommendedBox import org.openedx.core.system.notifier.AppUpgradeEvent import org.openedx.core.ui.HandleUIMessage @@ -63,7 +96,7 @@ import org.openedx.core.ui.windowSizeValue import org.openedx.core.utils.TimeUtils import org.openedx.dashboard.R import org.openedx.dashboard.presentation.DashboardRouter -import java.util.* +import java.util.Date class DashboardFragment : Fragment() { @@ -127,7 +160,7 @@ class DashboardFragment : Fragment() { } } -@OptIn(ExperimentalMaterialApi::class) +@OptIn(ExperimentalMaterialApi::class, ExperimentalComposeUiApi::class) @Composable internal fun MyCoursesScreen( windowSize: WindowSize, @@ -157,7 +190,11 @@ internal fun MyCoursesScreen( Scaffold( scaffoldState = scaffoldState, - modifier = Modifier.fillMaxSize(), + modifier = Modifier + .fillMaxSize() + .semantics { + testTagsAsResourceId = true + }, backgroundColor = MaterialTheme.appColors.background ) { paddingValues -> @@ -238,17 +275,7 @@ internal fun MyCoursesScreen( content = { item() { Column { - Text( - text = stringResource(id = R.string.dashboard_courses), - color = MaterialTheme.appColors.textPrimary, - style = MaterialTheme.appTypography.displaySmall - ) - Text( - modifier = Modifier.padding(top = 4.dp), - text = stringResource(id = R.string.dashboard_welcome_back), - color = MaterialTheme.appColors.textPrimary, - style = MaterialTheme.appTypography.titleSmall - ) + Header() Spacer(modifier = Modifier.height(16.dp)) } } @@ -290,17 +317,7 @@ internal fun MyCoursesScreen( .then(contentWidth) .then(emptyStatePaddings) ) { - Text( - text = stringResource(id = R.string.dashboard_courses), - color = MaterialTheme.appColors.textPrimary, - style = MaterialTheme.appTypography.displaySmall - ) - Text( - modifier = Modifier.padding(top = 4.dp), - text = stringResource(id = R.string.dashboard_welcome_back), - color = MaterialTheme.appColors.textPrimaryVariant, - style = MaterialTheme.appTypography.titleSmall - ) + Header() EmptyState() } } @@ -366,6 +383,7 @@ private fun CourseItem( val context = LocalContext.current Surface( modifier = Modifier + .testTag("btn_course_item") .height(142.dp) .fillMaxWidth() .clickable { onClick(enrolledCourse) } @@ -398,6 +416,7 @@ private fun CourseItem( .background(MaterialTheme.appColors.background) ) { Text( + modifier = Modifier.testTag("txt_course_org"), text = enrolledCourse.course.org, color = MaterialTheme.appColors.textFieldHint, style = MaterialTheme.appTypography.labelMedium @@ -410,6 +429,7 @@ private fun CourseItem( verticalArrangement = Arrangement.SpaceBetween ) { Text( + modifier = Modifier.testTag("txt_course_name"), text = enrolledCourse.course.name, color = MaterialTheme.appColors.textPrimary, style = MaterialTheme.appTypography.titleSmall, @@ -424,6 +444,7 @@ private fun CourseItem( horizontalArrangement = Arrangement.SpaceBetween ) { Text( + modifier = Modifier.testTag("txt_course_date"), text = TimeUtils.getCourseFormattedDate( context, Date(), @@ -444,6 +465,7 @@ private fun CourseItem( ) { Icon( modifier = Modifier + .testTag("ic_course_item") .size(15.dp), imageVector = Icons.Filled.ArrowForward, contentDescription = null, @@ -457,6 +479,24 @@ private fun CourseItem( } } +@Composable +private fun Header() { + Text( + modifier = Modifier.testTag("txt_courses_title"), + text = stringResource(id = R.string.dashboard_courses), + color = MaterialTheme.appColors.textPrimary, + style = MaterialTheme.appTypography.displaySmall + ) + Text( + modifier = Modifier + .testTag("txt_courses_description") + .padding(top = 4.dp), + text = stringResource(id = R.string.dashboard_welcome_back), + color = MaterialTheme.appColors.textPrimaryVariant, + style = MaterialTheme.appTypography.titleSmall + ) +} + @Composable private fun EmptyState() { Box( @@ -474,7 +514,9 @@ private fun EmptyState() { ) Spacer(Modifier.height(16.dp)) Text( - modifier = Modifier.fillMaxWidth(), + modifier = Modifier + .testTag("txt_empty_state_title") + .fillMaxWidth(), text = stringResource(id = R.string.dashboard_its_empty), color = MaterialTheme.appColors.textPrimary, style = MaterialTheme.appTypography.titleMedium, @@ -482,7 +524,9 @@ private fun EmptyState() { ) Spacer(Modifier.height(8.dp)) Text( - modifier = Modifier.fillMaxWidth(), + modifier = Modifier + .testTag("txt_empty_state_description") + .fillMaxWidth(), text = stringResource(id = R.string.dashboard_you_are_not_enrolled), color = MaterialTheme.appColors.textPrimaryVariant, style = MaterialTheme.appTypography.bodySmall, 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 index 00da95a6e..08dedc32e 100644 --- a/dashboard/src/main/java/org/openedx/dashboard/presentation/program/ProgramFragment.kt +++ b/dashboard/src/main/java/org/openedx/dashboard/presentation/program/ProgramFragment.kt @@ -25,12 +25,15 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember 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.platform.ComposeView import androidx.compose.ui.platform.LocalConfiguration import androidx.compose.ui.platform.ViewCompositionStrategy import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.semantics.testTagsAsResourceId import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp @@ -207,6 +210,7 @@ class ProgramFragment(private val myPrograms: Boolean = false) : Fragment() { } } +@OptIn(ExperimentalComposeUiApi::class) @Composable private fun ProgramInfoScreen( windowSize: WindowSize, @@ -235,7 +239,9 @@ private fun ProgramInfoScreen( Scaffold( scaffoldState = scaffoldState, - modifier = Modifier.fillMaxSize(), + modifier = Modifier + .fillMaxSize() + .semantics { testTagsAsResourceId = true }, backgroundColor = MaterialTheme.appColors.background ) { val modifierScreenWidth by remember(key1 = windowSize) { diff --git a/discovery/src/main/java/org/openedx/discovery/presentation/NativeDiscoveryFragment.kt b/discovery/src/main/java/org/openedx/discovery/presentation/NativeDiscoveryFragment.kt index b64e3b5b8..6db42a074 100644 --- a/discovery/src/main/java/org/openedx/discovery/presentation/NativeDiscoveryFragment.kt +++ b/discovery/src/main/java/org/openedx/discovery/presentation/NativeDiscoveryFragment.kt @@ -40,10 +40,14 @@ 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.platform.ComposeView 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.tooling.preview.Devices import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.Dp @@ -186,7 +190,7 @@ class NativeDiscoveryFragment : Fragment() { } -@OptIn(ExperimentalMaterialApi::class) +@OptIn(ExperimentalMaterialApi::class, ExperimentalComposeUiApi::class) @Composable internal fun DiscoveryScreen( windowSize: WindowSize, @@ -222,7 +226,11 @@ internal fun DiscoveryScreen( Scaffold( scaffoldState = scaffoldState, - modifier = Modifier.fillMaxSize(), + modifier = Modifier + .fillMaxSize() + .semantics { + testTagsAsResourceId = true + }, backgroundColor = MaterialTheme.appColors.background, bottomBar = { if (!isUserLoggedIn) { @@ -306,6 +314,7 @@ internal fun DiscoveryScreen( verticalArrangement = Arrangement.Center ) { Text( + modifier = Modifier.testTag("txt_discovery_title"), text = stringResource(id = R.string.discovery_Discovery), color = MaterialTheme.appColors.textPrimary, style = MaterialTheme.appTypography.titleMedium @@ -354,12 +363,15 @@ internal fun DiscoveryScreen( item { Column { Text( + modifier = Modifier.testTag("txt_discovery_new"), text = stringResource(id = R.string.discovery_discovery_new), color = MaterialTheme.appColors.textPrimary, style = MaterialTheme.appTypography.displaySmall ) Text( - modifier = Modifier.padding(top = 4.dp), + modifier = Modifier + .testTag("txt_discovery_lets_find") + .padding(top = 4.dp), text = stringResource(id = R.string.discovery_lets_find), color = MaterialTheme.appColors.textPrimary, style = MaterialTheme.appTypography.titleSmall diff --git a/discovery/src/main/java/org/openedx/discovery/presentation/WebViewDiscoveryFragment.kt b/discovery/src/main/java/org/openedx/discovery/presentation/WebViewDiscoveryFragment.kt index 33a2bd24a..f03e34d58 100644 --- a/discovery/src/main/java/org/openedx/discovery/presentation/WebViewDiscoveryFragment.kt +++ b/discovery/src/main/java/org/openedx/discovery/presentation/WebViewDiscoveryFragment.kt @@ -28,6 +28,7 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember 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.platform.ComposeView @@ -35,6 +36,8 @@ 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.semantics.semantics +import androidx.compose.ui.semantics.testTagsAsResourceId import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp @@ -148,6 +151,7 @@ class WebViewDiscoveryFragment : Fragment() { } } +@OptIn(ExperimentalComposeUiApi::class) @Composable @SuppressLint("SetJavaScriptEnabled") private fun WebViewDiscoveryScreen( @@ -169,7 +173,11 @@ private fun WebViewDiscoveryScreen( Scaffold( scaffoldState = scaffoldState, - modifier = Modifier.fillMaxSize(), + modifier = Modifier + .fillMaxSize() + .semantics { + testTagsAsResourceId = true + }, backgroundColor = MaterialTheme.appColors.background, bottomBar = { if (isPreLogin) { 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 26af85022..f72540b14 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 @@ -45,8 +45,11 @@ 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.platform.testTag import androidx.compose.ui.res.pluralStringResource import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.semantics.testTagsAsResourceId import androidx.compose.ui.text.TextRange import androidx.compose.ui.text.input.TextFieldValue import androidx.compose.ui.text.style.TextAlign @@ -205,7 +208,8 @@ private fun CourseSearchScreen( scaffoldState = scaffoldState, modifier = Modifier .fillMaxSize() - .navigationBarsPadding(), + .navigationBarsPadding() + .semantics { testTagsAsResourceId = true }, backgroundColor = MaterialTheme.appColors.background, bottomBar = { if (!isUserLoggedIn) { @@ -282,6 +286,7 @@ private fun CourseSearchScreen( } Text( modifier = Modifier + .testTag("txt_search_title") .fillMaxWidth() .padding(horizontal = 56.dp), text = stringResource(id = org.openedx.core.R.string.core_search), @@ -340,12 +345,15 @@ private fun CourseSearchScreen( item { Column { Text( + modifier = Modifier.testTag("txt_search_results_title"), text = stringResource(id = discoveryR.string.discovery_search_results), color = MaterialTheme.appColors.textPrimary, style = MaterialTheme.appTypography.displaySmall ) Text( - modifier = Modifier.padding(top = 4.dp), + modifier = Modifier + .testTag("txt_search_results_subtitle") + .padding(top = 4.dp), text = typingText, color = MaterialTheme.appColors.textPrimary, style = MaterialTheme.appTypography.titleSmall 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 701fa4bb0..50771187a 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 @@ -31,11 +31,15 @@ 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.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.SpanStyle import androidx.compose.ui.text.buildAnnotatedString import androidx.compose.ui.text.input.ImeAction @@ -52,11 +56,11 @@ 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.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.Toolbar import org.openedx.core.ui.WindowSize import org.openedx.core.ui.WindowType import org.openedx.core.ui.displayCutoutForLandscape @@ -128,6 +132,7 @@ class DeleteProfileFragment : Fragment() { } +@OptIn(ExperimentalComposeUiApi::class) @Composable fun DeleteProfileScreen( windowSize: WindowSize, @@ -153,7 +158,8 @@ fun DeleteProfileScreen( Scaffold( modifier = Modifier .fillMaxSize() - .navigationBarsPadding(), + .navigationBarsPadding() + .semantics { testTagsAsResourceId = true }, scaffoldState = scaffoldState ) { paddingValues -> @@ -191,26 +197,12 @@ fun DeleteProfileScreen( .displayCutoutForLandscape(), horizontalAlignment = Alignment.CenterHorizontally ) { - Box( + Toolbar( modifier = topBarWidth, - contentAlignment = Alignment.CenterStart - ) { - Text( - modifier = Modifier - .fillMaxWidth(), - text = stringResource(id = profileR.string.profile_delete_account), - color = MaterialTheme.appColors.textPrimary, - textAlign = TextAlign.Center, - style = MaterialTheme.appTypography.titleMedium - ) - - BackBtn( - modifier = Modifier.padding(end = 8.dp) - ) { - onBackClick() - } - } - + label = stringResource(id = profileR.string.profile_delete_account), + canShowBackBtn = true, + onBackClick = onBackClick + ) Column( Modifier .fillMaxHeight() @@ -226,7 +218,9 @@ fun DeleteProfileScreen( ) Spacer(Modifier.height(32.dp)) Text( - modifier = Modifier.fillMaxWidth(), + modifier = Modifier + .testTag("txt_delete_account_title") + .fillMaxWidth(), text = buildAnnotatedString { append(stringResource(id = profileR.string.profile_you_want_to)) append(" ") @@ -251,7 +245,9 @@ fun DeleteProfileScreen( ) Spacer(Modifier.height(16.dp)) Text( - modifier = Modifier.fillMaxWidth(), + modifier = Modifier + .testTag("txt_delete_account_description") + .fillMaxWidth(), text = stringResource(id = profileR.string.profile_confirm_action), style = MaterialTheme.appTypography.labelLarge, color = MaterialTheme.appColors.textSecondary, @@ -323,4 +319,4 @@ fun DeleteProfileScreenPreview() { onDeleteClick = {} ) } -} \ No newline at end of file +} 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 e911d1b4b..1efdc094e 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 @@ -16,7 +16,6 @@ import android.provider.MediaStore import android.view.Gravity import android.view.LayoutInflater import android.view.ViewGroup -import androidx.activity.OnBackPressedCallback import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.foundation.background import androidx.compose.foundation.border @@ -84,8 +83,11 @@ import androidx.compose.ui.platform.LocalFocusManager import androidx.compose.ui.platform.LocalSoftwareKeyboardController import androidx.compose.ui.platform.LocalView 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.input.KeyboardType import androidx.compose.ui.text.input.TextFieldValue @@ -107,13 +109,13 @@ 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.AppDataConstants.DEFAULT_MIME_TYPE -import org.openedx.core.R import org.openedx.core.UIMessage 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.extension.tagId import org.openedx.core.ui.AutoSizeText import org.openedx.core.ui.BackBtn import org.openedx.core.ui.HandleUIMessage @@ -135,12 +137,13 @@ 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.R import org.openedx.profile.domain.model.Account import org.openedx.profile.presentation.ProfileRouter import java.io.ByteArrayOutputStream import java.io.File import java.io.FileOutputStream -import org.openedx.profile.R as profileR +import org.openedx.core.R as coreR private const val BIO_TEXT_FIELD_LIMIT = 300 @@ -159,21 +162,6 @@ class EditProfileFragment : Fragment() { } } - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - - val callback = requireActivity() - .onBackPressedDispatcher.addCallback(this, object : OnBackPressedCallback(true) { - override fun handleOnBackPressed() { - if (viewModel.profileDataChanged) { - viewModel.setShowLeaveDialog(true) - } else { - requireActivity().supportFragmentManager.popBackStack() - } - } - }) - } - override fun onCreateView( inflater: LayoutInflater, container: ViewGroup?, @@ -256,6 +244,7 @@ class EditProfileFragment : Fragment() { } } + @Suppress("DEPRECATION") private fun cropImage(uri: Uri): Uri { val matrix = Matrix() matrix.postRotate(getImageOrientation(uri).toFloat()) @@ -398,15 +387,21 @@ private fun EditProfileScreen( } val imageRes: Any = if (!isImageDeleted) { - if (selectedImageUri != null) { - selectedImageUri.toString() - } else if (uiState.account.profileImage.hasImage) { - uiState.account.profileImage.imageUrlFull - } else { - R.drawable.core_ic_default_profile_picture + when { + selectedImageUri != null -> { + selectedImageUri.toString() + } + + uiState.account.profileImage.hasImage -> { + uiState.account.profileImage.imageUrlFull + } + + else -> { + coreR.drawable.core_ic_default_profile_picture + } } } else { - R.drawable.core_ic_default_profile_picture + coreR.drawable.core_ic_default_profile_picture } val modalListState = rememberLazyListState() @@ -426,7 +421,10 @@ private fun EditProfileScreen( Scaffold( modifier = Modifier .fillMaxSize() - .navigationBarsPadding(), + .navigationBarsPadding() + .semantics { + testTagsAsResourceId = true + }, scaffoldState = scaffoldState ) { paddingValues -> @@ -467,6 +465,7 @@ private fun EditProfileScreen( ModalBottomSheetLayout( modifier = Modifier + .testTag("btn_bottom_sheet_edit_profile") .padding(bottom = if (isImeVisible && bottomSheetScaffoldState.isVisible) 120.dp else 0.dp) .noRippleClickable { if (bottomSheetScaffoldState.isVisible) { @@ -560,8 +559,9 @@ private fun EditProfileScreen( ) { Text( modifier = Modifier + .testTag("txt_edit_profile_title") .fillMaxWidth(), - text = stringResource(id = profileR.string.profile_edit_profile), + text = stringResource(id = R.string.profile_edit_profile), color = MaterialTheme.appColors.textPrimary, textAlign = TextAlign.Center, style = MaterialTheme.appTypography.titleMedium @@ -579,7 +579,7 @@ private fun EditProfileScreen( modifier = Modifier .height(48.dp) .padding(end = 24.dp), - text = stringResource(id = profileR.string.profile_done), + text = stringResource(id = R.string.profile_done), icon = Icons.Filled.Done, color = MaterialTheme.appColors.primary, textStyle = MaterialTheme.appTypography.labelLarge, @@ -609,7 +609,8 @@ private fun EditProfileScreen( horizontalAlignment = Alignment.CenterHorizontally ) { Text( - text = stringResource(if (uiState.isLimited) profileR.string.profile_limited_profile else profileR.string.profile_full_profile), + modifier = Modifier.testTag("txt_edit_profile_type_label"), + text = stringResource(if (uiState.isLimited) R.string.profile_limited_profile else R.string.profile_full_profile), color = MaterialTheme.appColors.textSecondary, style = MaterialTheme.appTypography.titleSmall ) @@ -618,12 +619,16 @@ private fun EditProfileScreen( AsyncImage( model = ImageRequest.Builder(LocalContext.current) .data(imageRes) - .error(R.drawable.core_ic_default_profile_picture) - .placeholder(R.drawable.core_ic_default_profile_picture) + .error(coreR.drawable.core_ic_default_profile_picture) + .placeholder(coreR.drawable.core_ic_default_profile_picture) .build(), contentScale = ContentScale.Crop, - contentDescription = stringResource(id = R.string.core_accessibility_user_profile_image, uiState.account.username), + contentDescription = stringResource( + id = coreR.string.core_accessibility_user_profile_image, + uiState.account.username + ), modifier = Modifier + .testTag("img_edit_profile_user_image") .border( 2.dp, MaterialTheme.appColors.onSurface, @@ -646,33 +651,36 @@ private fun EditProfileScreen( .clip(CircleShape) .background(MaterialTheme.appColors.primary) .padding(5.dp), - painter = painterResource(id = profileR.drawable.profile_ic_edit_image), + painter = painterResource(id = R.drawable.profile_ic_edit_image), contentDescription = null, tint = Color.White ) } Spacer(modifier = Modifier.height(20.dp)) Text( + modifier = Modifier.testTag("txt_edit_profile_user_name"), text = uiState.account.name, style = MaterialTheme.appTypography.headlineSmall, color = MaterialTheme.appColors.textPrimary ) Spacer(modifier = Modifier.height(24.dp)) Text( - modifier = Modifier.clickable { - if (!LocaleUtils.isProfileLimited(mapFields[YEAR_OF_BIRTH].toString())) { - val privacy = if (uiState.isLimited) { - Account.Privacy.ALL_USERS + modifier = Modifier + .testTag("txt_edit_profile_limited_profile_label") + .clickable { + if (!LocaleUtils.isProfileLimited(mapFields[YEAR_OF_BIRTH].toString())) { + val privacy = if (uiState.isLimited) { + Account.Privacy.ALL_USERS + } else { + Account.Privacy.PRIVATE + } + mapFields[ACCOUNT_PRIVACY] = privacy + onLimitedProfileChange(!uiState.isLimited) } else { - Account.Privacy.PRIVATE + openWarningMessageDialog = true } - mapFields[ACCOUNT_PRIVACY] = privacy - onLimitedProfileChange(!uiState.isLimited) - } else { - openWarningMessageDialog = true - } - }, - text = stringResource(if (uiState.isLimited) profileR.string.profile_switch_to_full else profileR.string.profile_switch_to_limited), + }, + text = stringResource(if (uiState.isLimited) R.string.profile_switch_to_full else R.string.profile_switch_to_limited), color = MaterialTheme.appColors.textAccent, style = MaterialTheme.appTypography.labelLarge ) @@ -680,17 +688,23 @@ private fun EditProfileScreen( ProfileFields( disabled = uiState.isLimited, onFieldClick = { it, title -> - if (it == YEAR_OF_BIRTH) { - serverFieldName.value = YEAR_OF_BIRTH - expandedList = - LocaleUtils.getBirthYearsRange() - } else if (it == COUNTRY) { - serverFieldName.value = COUNTRY - expandedList = - LocaleUtils.getCountries() - } else if (it == LANGUAGE) { - serverFieldName.value = LANGUAGE - expandedList = LocaleUtils.getLanguages() + when (it) { + YEAR_OF_BIRTH -> { + serverFieldName.value = YEAR_OF_BIRTH + expandedList = + LocaleUtils.getBirthYearsRange() + } + + COUNTRY -> { + serverFieldName.value = COUNTRY + expandedList = + LocaleUtils.getCountries() + } + + LANGUAGE -> { + serverFieldName.value = LANGUAGE + expandedList = LocaleUtils.getLanguages() + } } bottomDialogTitle = title keyboardController?.hide() @@ -720,8 +734,8 @@ private fun EditProfileScreen( ) Spacer(Modifier.height(40.dp)) IconText( - text = stringResource(id = org.openedx.profile.R.string.profile_delete_profile), - painter = painterResource(id = profileR.drawable.profile_ic_trash), + text = stringResource(id = R.string.profile_delete_profile), + painter = painterResource(id = R.drawable.profile_ic_trash), textStyle = MaterialTheme.appTypography.labelLarge, color = MaterialTheme.appColors.error, onClick = { @@ -773,13 +787,17 @@ private fun LimitedProfileDialog( ) Spacer(modifier = Modifier.width(8.dp)) Text( - modifier = Modifier.weight(1f), - text = stringResource(id = profileR.string.profile_oh_sorry), + modifier = Modifier + .testTag("txt_edit_profile_limited_profile_title") + .weight(1f), + text = stringResource(id = R.string.profile_oh_sorry), color = MaterialTheme.appColors.textDark, style = MaterialTheme.appTypography.titleMedium ) Icon( - modifier = Modifier.clickable { onCloseClick() }, + modifier = Modifier + .testTag("ic_edit_profile_limited_profile_close") + .clickable { onCloseClick() }, imageVector = Icons.Filled.Close, contentDescription = null, tint = MaterialTheme.appColors.textDark @@ -787,8 +805,10 @@ private fun LimitedProfileDialog( } Spacer(modifier = Modifier.height(8.dp)) Text( - modifier = Modifier.fillMaxWidth(), - text = stringResource(id = profileR.string.profile_must_be_over), + modifier = Modifier + .testTag("txt_edit_profile_limited_profile_message") + .fillMaxWidth(), + text = stringResource(id = R.string.profile_must_be_over), color = MaterialTheme.appColors.textDark, style = MaterialTheme.appTypography.bodyMedium ) @@ -808,7 +828,9 @@ private fun ChangeImageDialog( val dialogWindowProvider = LocalView.current.parent as DialogWindowProvider dialogWindowProvider.window.setGravity(Gravity.BOTTOM) Box( - Modifier.padding(bottom = 24.dp) + Modifier + .padding(bottom = 24.dp) + .semantics { testTagsAsResourceId = true } ) { Column( Modifier @@ -833,18 +855,20 @@ private fun ChangeImageDialog( ) Spacer(Modifier.height(14.dp)) Text( - text = stringResource(id = profileR.string.profile_change_image), + modifier = Modifier.testTag("txt_edit_profile_change_image_title"), + text = stringResource(id = R.string.profile_change_image), style = MaterialTheme.appTypography.titleLarge, color = MaterialTheme.appColors.textPrimary ) Spacer(Modifier.height(20.dp)) OpenEdXButton( - text = stringResource(id = profileR.string.profile_select_from_gallery), + text = stringResource(id = R.string.profile_select_from_gallery), onClick = onSelectFromGalleryClick, content = { IconText( - text = stringResource(id = profileR.string.profile_select_from_gallery), - painter = painterResource(id = profileR.drawable.profile_ic_gallery), + modifier = Modifier.testTag("it_select_from_gallery"), + text = stringResource(id = R.string.profile_select_from_gallery), + painter = painterResource(id = R.drawable.profile_ic_gallery), color = Color.White, textStyle = MaterialTheme.appTypography.labelLarge ) @@ -854,12 +878,13 @@ private fun ChangeImageDialog( OpenEdXOutlinedButton( borderColor = MaterialTheme.appColors.error, textColor = MaterialTheme.appColors.textPrimary, - text = stringResource(id = profileR.string.profile_remove_photo), + text = stringResource(id = R.string.profile_remove_photo), onClick = onRemoveImageClick, content = { IconText( - text = stringResource(id = profileR.string.profile_remove_photo), - painter = painterResource(id = profileR.drawable.profile_ic_remove_image), + modifier = Modifier.testTag("it_remove_photo"), + text = stringResource(id = R.string.profile_remove_photo), + painter = painterResource(id = R.drawable.profile_ic_remove_image), color = MaterialTheme.appColors.error, textStyle = MaterialTheme.appTypography.labelLarge ) @@ -869,7 +894,7 @@ private fun ChangeImageDialog( OpenEdXOutlinedButton( borderColor = MaterialTheme.appColors.textPrimaryVariant, textColor = MaterialTheme.appColors.textPrimary, - text = stringResource(id = R.string.core_cancel), + text = stringResource(id = coreR.string.core_cancel), onClick = onCancelClick ) Spacer(Modifier.height(20.dp)) @@ -894,27 +919,27 @@ private fun ProfileFields( } else "" Column(verticalArrangement = Arrangement.spacedBy(20.dp)) { SelectableField( - name = stringResource(id = profileR.string.profile_year), + name = stringResource(id = R.string.profile_year), initialValue = mapFields[YEAR_OF_BIRTH].toString(), onClick = { - onFieldClick(YEAR_OF_BIRTH, context.getString(profileR.string.profile_year)) + onFieldClick(YEAR_OF_BIRTH, context.getString(R.string.profile_year)) } ) if (!disabled) { SelectableField( - name = stringResource(id = profileR.string.profile_location), + name = stringResource(id = R.string.profile_location), initialValue = LocaleUtils.getCountryByCountryCode(mapFields[COUNTRY].toString()), onClick = { - onFieldClick(COUNTRY, context.getString(profileR.string.profile_location)) + onFieldClick(COUNTRY, context.getString(R.string.profile_location)) } ) SelectableField( - name = stringResource(id = profileR.string.profile_spoken_language), + name = stringResource(id = R.string.profile_spoken_language), initialValue = lang, onClick = { onFieldClick( LANGUAGE, - context.getString(profileR.string.profile_spoken_language) + context.getString(R.string.profile_spoken_language) ) } ) @@ -922,7 +947,7 @@ private fun ProfileFields( modifier = Modifier .fillMaxWidth() .height(132.dp), - name = stringResource(id = profileR.string.profile_about_me), + name = stringResource(id = R.string.profile_about_me), initialValue = mapFields[BIO].toString(), onValueChanged = { onValueChanged(it.take(BIO_TEXT_FIELD_LIMIT)) @@ -954,9 +979,11 @@ private fun SelectableField( disabledPlaceholderColor = MaterialTheme.appColors.textFieldHint ) } - Column() { + Column { Text( - modifier = Modifier.fillMaxWidth(), + modifier = Modifier + .testTag("txt_label_${name.tagId()}") + .fillMaxWidth(), text = name, style = MaterialTheme.appTypography.labelLarge, color = MaterialTheme.appColors.textPrimary @@ -978,12 +1005,14 @@ private fun SelectableField( ) }, modifier = Modifier + .testTag("tf_select_${name.tagId()}") .fillMaxWidth() .noRippleClickable { onClick() }, placeholder = { Text( + modifier = Modifier.testTag("txt_placeholder_${name.tagId()}"), text = name, color = MaterialTheme.appColors.textFieldHint, style = MaterialTheme.appTypography.bodyMedium @@ -1008,7 +1037,9 @@ private fun InputEditField( Column { Text( - modifier = Modifier.fillMaxWidth(), + modifier = Modifier + .testTag("txt_label_${name.tagId()}") + .fillMaxWidth(), text = name, style = MaterialTheme.appTypography.labelLarge, color = MaterialTheme.appColors.textPrimary @@ -1027,6 +1058,7 @@ private fun InputEditField( shape = MaterialTheme.appShapes.textFieldShape, placeholder = { Text( + modifier = Modifier.testTag("txt_placeholder_${name.tagId()}"), text = name, color = MaterialTheme.appColors.textFieldHint, style = MaterialTheme.appTypography.bodyMedium @@ -1042,7 +1074,7 @@ private fun InputEditField( onDoneClick() }, textStyle = MaterialTheme.appTypography.bodyMedium, - modifier = modifier + modifier = modifier.testTag("tf_input_${name.tagId()}") ) } } @@ -1072,38 +1104,47 @@ private fun LeaveProfile( MaterialTheme.appShapes.cardShape ) .padding(horizontal = 40.dp) - .padding(top = 48.dp, bottom = 36.dp), + .padding(top = 48.dp, bottom = 36.dp) + .semantics { + testTagsAsResourceId = true + }, horizontalAlignment = Alignment.CenterHorizontally ) { Icon( modifier = Modifier .size(100.dp), - painter = painterResource(org.openedx.profile.R.drawable.profile_ic_save), + painter = painterResource(R.drawable.profile_ic_save), contentDescription = null ) Spacer(Modifier.size(48.dp)) Text( - text = stringResource(id = org.openedx.profile.R.string.profile_leave_profile), + modifier = Modifier + .testTag("txt_leave_profile_title"), + text = stringResource(id = R.string.profile_leave_profile), color = MaterialTheme.appColors.textPrimary, style = MaterialTheme.appTypography.titleLarge, textAlign = TextAlign.Center ) Spacer(Modifier.size(12.dp)) Text( - text = stringResource(id = org.openedx.profile.R.string.profile_changes_you_made), + modifier = Modifier + .testTag("txt_leave_profile_description"), + text = stringResource(id = R.string.profile_changes_you_made), color = MaterialTheme.appColors.textPrimary, style = MaterialTheme.appTypography.bodyMedium, textAlign = TextAlign.Center ) Spacer(Modifier.size(40.dp)) OpenEdXButton( - text = stringResource(id = org.openedx.profile.R.string.profile_leave), + text = stringResource(id = R.string.profile_leave), onClick = onLeaveClick, backgroundColor = MaterialTheme.appColors.warning, content = { Text( - modifier = Modifier.fillMaxWidth(), - text = stringResource(id = org.openedx.profile.R.string.profile_leave), + modifier = Modifier + .testTag("txt_leave") + .fillMaxWidth(), + text = stringResource(id = R.string.profile_leave), color = MaterialTheme.appColors.textDark, style = MaterialTheme.appTypography.labelLarge, textAlign = TextAlign.Center @@ -1114,7 +1155,7 @@ private fun LeaveProfile( OpenEdXOutlinedButton( borderColor = MaterialTheme.appColors.textPrimary, textColor = MaterialTheme.appColors.textPrimary, - text = stringResource(id = org.openedx.profile.R.string.profile_keep_editing), + text = stringResource(id = R.string.profile_keep_editing), onClick = onDismissRequest ) } @@ -1130,12 +1171,17 @@ private fun LeaveProfileLandscape( val screenWidth = configuration.screenWidthDp.dp Dialog( onDismissRequest = onDismissRequest, - properties = DialogProperties(dismissOnBackPress = false, dismissOnClickOutside = false, usePlatformDefaultWidth = false), + properties = DialogProperties( + dismissOnBackPress = false, + dismissOnClickOutside = false, + usePlatformDefaultWidth = false + ), content = { Card( modifier = Modifier .width(screenWidth * 0.7f) - .clip(MaterialTheme.appShapes.courseImageShape), + .clip(MaterialTheme.appShapes.courseImageShape) + .semantics { testTagsAsResourceId = true }, backgroundColor = MaterialTheme.appColors.background, shape = MaterialTheme.appShapes.courseImageShape ) { @@ -1152,22 +1198,26 @@ private fun LeaveProfileLandscape( ) { Icon( modifier = Modifier.size(100.dp), - painter = painterResource(id = org.openedx.profile.R.drawable.profile_ic_save), + painter = painterResource(id = 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), + modifier = Modifier + .testTag("txt_leave_profile_dialog_title") + .fillMaxWidth(), + text = stringResource(id = 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), + modifier = Modifier + .testTag("txt_leave_profile_dialog_description") + .fillMaxWidth(), + text = stringResource(id = R.string.profile_changes_you_made), color = MaterialTheme.appColors.textFieldText, style = MaterialTheme.appTypography.titleSmall, textAlign = TextAlign.Center @@ -1179,11 +1229,12 @@ private fun LeaveProfileLandscape( horizontalAlignment = Alignment.CenterHorizontally ) { OpenEdXButton( - text = stringResource(id = org.openedx.profile.R.string.profile_leave), + text = stringResource(id = R.string.profile_leave), backgroundColor = MaterialTheme.appColors.warning, content = { AutoSizeText( - text = stringResource(id = org.openedx.profile.R.string.profile_leave), + modifier = Modifier.testTag("txt_leave_profile_dialog_leave"), + text = stringResource(id = R.string.profile_leave), style = MaterialTheme.appTypography.bodyMedium, color = MaterialTheme.appColors.textDark ) @@ -1194,11 +1245,13 @@ private fun LeaveProfileLandscape( OpenEdXOutlinedButton( borderColor = MaterialTheme.appColors.textPrimary, textColor = MaterialTheme.appColors.textPrimary, - text = stringResource(id = org.openedx.profile.R.string.profile_keep_editing), + text = stringResource(id = R.string.profile_keep_editing), onClick = onDismissRequest, content = { AutoSizeText( - text = stringResource(id = org.openedx.profile.R.string.profile_keep_editing), + modifier = Modifier + .testTag("btn_leave_profile_dialog_keep_editing"), + text = stringResource(id = R.string.profile_keep_editing), style = MaterialTheme.appTypography.bodyMedium, color = MaterialTheme.appColors.textPrimary ) @@ -1228,6 +1281,25 @@ fun LeaveProfileLandscapePreview() { ) } +@Preview +@Composable +fun ChangeProfileImagePreview() { + ChangeImageDialog( + onSelectFromGalleryClick = {}, + onRemoveImageClick = {}, + onCancelClick = {} + ) +} + +@Preview +@Composable +fun LimitedProfilePreview() { + LimitedProfileDialog( + modifier = Modifier, + onCloseClick = {} + ) +} + @Preview(uiMode = Configuration.UI_MODE_NIGHT_NO) @Preview(uiMode = UI_MODE_NIGHT_YES) @Preview(name = "NEXUS_5_Light", device = Devices.NEXUS_5, uiMode = Configuration.UI_MODE_NIGHT_NO) 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 index cd544a7a7..9ae81cc05 100644 --- 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 @@ -46,12 +46,16 @@ 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.graphics.Color 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.semantics.semantics +import androidx.compose.ui.semantics.testTagsAsResourceId import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.Devices @@ -63,6 +67,7 @@ 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.extension.tagId import org.openedx.core.presentation.global.AppData import org.openedx.core.system.notifier.AppUpgradeEvent import org.openedx.core.ui.HandleUIMessage @@ -83,7 +88,7 @@ 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) +@OptIn(ExperimentalMaterialApi::class, ExperimentalComposeUiApi::class) @Composable internal fun ProfileView( windowSize: WindowSize, @@ -101,7 +106,11 @@ internal fun ProfileView( onRefresh = { onAction(ProfileViewAction.SwipeRefresh) }) Scaffold( - modifier = Modifier.fillMaxSize(), + modifier = Modifier + .fillMaxSize() + .semantics { + testTagsAsResourceId = true + }, scaffoldState = scaffoldState ) { paddingValues -> @@ -153,6 +162,7 @@ internal fun ProfileView( ) { Text( modifier = Modifier + .testTag("txt_profile_title") .fillMaxWidth(), text = stringResource(id = R.string.core_profile), color = MaterialTheme.appColors.textPrimary, @@ -162,6 +172,7 @@ internal fun ProfileView( IconText( modifier = Modifier + .testTag("it_edit_account") .height(48.dp) .padding(end = 24.dp), text = stringResource(org.openedx.profile.R.string.profile_edit), @@ -251,6 +262,7 @@ internal fun ProfileView( private fun SettingsSection(onVideoSettingsClick: () -> Unit) { Column { Text( + modifier = Modifier.testTag("txt_settings"), text = stringResource(id = org.openedx.profile.R.string.profile_settings), style = MaterialTheme.appTypography.labelLarge, color = MaterialTheme.appColors.textSecondary @@ -280,6 +292,7 @@ private fun SupportInfoSection( ) { Column { Text( + modifier = Modifier.testTag("txt_support_info"), text = stringResource(id = org.openedx.profile.R.string.profile_support_info), style = MaterialTheme.appTypography.labelLarge, color = MaterialTheme.appColors.textSecondary @@ -366,6 +379,7 @@ private fun SupportInfoSection( private fun LogoutButton(onClick: () -> Unit) { Card( modifier = Modifier + .testTag("btn_logout") .fillMaxWidth() .clickable { onClick() @@ -379,6 +393,7 @@ private fun LogoutButton(onClick: () -> Unit) { horizontalArrangement = Arrangement.SpaceBetween ) { Text( + modifier = Modifier.testTag("txt_logout"), text = stringResource(id = org.openedx.profile.R.string.profile_logout), style = MaterialTheme.appTypography.titleMedium, color = MaterialTheme.appColors.error @@ -392,6 +407,7 @@ private fun LogoutButton(onClick: () -> Unit) { } } +@OptIn(ExperimentalComposeUiApi::class) @Composable private fun LogoutDialog( onDismissRequest: () -> Unit, @@ -414,7 +430,8 @@ private fun LogoutDialog( MaterialTheme.appColors.cardViewBorder, MaterialTheme.appShapes.cardShape ) - .padding(horizontal = 40.dp, vertical = 36.dp), + .padding(horizontal = 40.dp, vertical = 36.dp) + .semantics { testTagsAsResourceId = true }, horizontalAlignment = Alignment.CenterHorizontally ) { Box( @@ -422,7 +439,9 @@ private fun LogoutDialog( contentAlignment = Alignment.CenterEnd ) { IconButton( - modifier = Modifier.size(24.dp), + modifier = Modifier + .testTag("ib_close") + .size(24.dp), onClick = onDismissRequest ) { Icon( @@ -442,6 +461,7 @@ private fun LogoutDialog( ) Spacer(Modifier.size(36.dp)) Text( + modifier = Modifier.testTag("txt_logout_dialog_title"), text = stringResource(id = org.openedx.profile.R.string.profile_logout_dialog_body), color = MaterialTheme.appColors.textPrimary, style = MaterialTheme.appTypography.titleLarge, @@ -455,17 +475,22 @@ private fun LogoutDialog( content = { Box( Modifier + .testTag("btn_logout") .fillMaxWidth(), contentAlignment = Alignment.CenterEnd ) { Text( - modifier = Modifier.fillMaxWidth(), + modifier = Modifier + .testTag("txt_logout") + .fillMaxWidth(), text = stringResource(id = org.openedx.profile.R.string.profile_logout), color = MaterialTheme.appColors.textDark, style = MaterialTheme.appTypography.labelLarge, textAlign = TextAlign.Center ) Icon( + modifier = Modifier + .testTag("ic_logout"), painter = painterResource(id = org.openedx.profile.R.drawable.profile_ic_logout), contentDescription = null, tint = Color.Black @@ -491,6 +516,7 @@ private fun ProfileInfoItem( } Row( Modifier + .testTag("btn_${text.tagId()}") .fillMaxWidth() .clickable { onClick() } .padding(20.dp), @@ -498,7 +524,9 @@ private fun ProfileInfoItem( verticalAlignment = Alignment.CenterVertically ) { Text( - modifier = Modifier.weight(1f), + modifier = Modifier + .testTag("txt_${text.tagId()}") + .weight(1f), text = text, maxLines = 1, overflow = TextOverflow.Ellipsis, @@ -553,6 +581,7 @@ private fun AppVersionItemAppToDate(versionName: String) { verticalArrangement = Arrangement.spacedBy(4.dp) ) { Text( + modifier = Modifier.testTag("txt_app_version_code"), text = stringResource(id = R.string.core_version, versionName), style = MaterialTheme.appTypography.titleMedium, color = MaterialTheme.appColors.textPrimary @@ -570,6 +599,7 @@ private fun AppVersionItemAppToDate(versionName: String) { tint = MaterialTheme.appColors.accessGreen ) Text( + modifier = Modifier.testTag("txt_up_to_date"), text = stringResource(id = R.string.core_up_to_date), color = MaterialTheme.appColors.textSecondary, style = MaterialTheme.appTypography.labelLarge @@ -586,6 +616,7 @@ private fun AppVersionItemUpgradeRecommended( ) { Row( modifier = Modifier + .testTag("btn_upgrade_recommended") .fillMaxWidth() .clickable { onClick() @@ -597,11 +628,13 @@ private fun AppVersionItemUpgradeRecommended( verticalArrangement = Arrangement.spacedBy(4.dp) ) { Text( + modifier = Modifier.testTag("txt_app_version_code"), text = stringResource(id = R.string.core_version, versionName), style = MaterialTheme.appTypography.titleMedium, color = MaterialTheme.appColors.textPrimary ) Text( + modifier = Modifier.testTag("txt_upgrade_recommended"), text = stringResource( id = R.string.core_tap_to_update_to_version, appUpgradeEvent.newVersionName @@ -626,6 +659,7 @@ fun AppVersionItemUpgradeRequired( ) { Row( modifier = Modifier + .testTag("btn_upgrade_required") .fillMaxWidth() .clickable { onClick() @@ -646,12 +680,14 @@ fun AppVersionItemUpgradeRequired( contentDescription = null ) Text( + modifier = Modifier.testTag("txt_app_version_code"), text = stringResource(id = R.string.core_version, versionName), style = MaterialTheme.appTypography.titleMedium, color = MaterialTheme.appColors.textPrimary ) } Text( + modifier = Modifier.testTag("txt_upgrade_required"), text = stringResource(id = R.string.core_tap_to_install_required_app_update), color = MaterialTheme.appColors.textAccent, style = MaterialTheme.appTypography.labelLarge @@ -747,7 +783,7 @@ private val mockAppData = AppData( versionName = "1.0.0", ) -private val mockAccount = Account( +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, 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 46c645a76..cc76fc859 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 @@ -6,34 +6,59 @@ import android.os.Bundle import android.view.LayoutInflater import android.view.ViewGroup 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.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.widthIn import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll -import androidx.compose.material.* +import androidx.compose.material.Divider +import androidx.compose.material.Icon +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.Done +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.ui.Alignment +import androidx.compose.ui.ExperimentalComposeUiApi import androidx.compose.ui.Modifier import androidx.compose.ui.platform.ComposeView import androidx.compose.ui.platform.ViewCompositionStrategy +import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.semantics.testTagsAsResourceId 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.openedx.core.R +import org.koin.androidx.viewmodel.ext.android.viewModel import org.openedx.core.domain.model.VideoQuality -import org.openedx.core.ui.* +import org.openedx.core.extension.nonZero +import org.openedx.core.extension.tagId +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.theme.appTypography -import org.koin.androidx.viewmodel.ext.android.viewModel +import org.openedx.core.ui.windowSizeValue import org.openedx.profile.R as profileR class VideoQualityFragment : Fragment() { @@ -54,7 +79,7 @@ class VideoQualityFragment : Fragment() { VideoQualityScreen( windowSize = windowSize, - videoQuality = videoQuality, + selectedVideoQuality = videoQuality, onQualityChanged = { viewModel.setVideoDownloadQuality(it) }, @@ -67,10 +92,11 @@ class VideoQualityFragment : Fragment() { } +@OptIn(ExperimentalComposeUiApi::class) @Composable private fun VideoQualityScreen( windowSize: WindowSize, - videoQuality: VideoQuality, + selectedVideoQuality: VideoQuality, onQualityChanged: (VideoQuality) -> Unit, onBackClick: () -> Unit ) { @@ -78,7 +104,10 @@ private fun VideoQualityScreen( Scaffold( modifier = Modifier .fillMaxSize() - .navigationBarsPadding(), + .navigationBarsPadding() + .semantics { + testTagsAsResourceId = true + }, scaffoldState = scaffoldState, ) { paddingValues -> @@ -114,22 +143,12 @@ private fun VideoQualityScreen( .displayCutoutForLandscape(), horizontalAlignment = Alignment.CenterHorizontally ) { - Box( + Toolbar( modifier = topBarWidth, - contentAlignment = Alignment.CenterStart - ) { - Text( - modifier = Modifier - .fillMaxWidth(), - text = stringResource(id = profileR.string.profile_video_streaming_quality), - color = MaterialTheme.appColors.textPrimary, - textAlign = TextAlign.Center, - style = MaterialTheme.appTypography.titleMedium - ) - BackBtn(Modifier.padding(start = 8.dp)) { - onBackClick() - } - } + label = stringResource(id = profileR.string.profile_video_streaming_quality), + canShowBackBtn = true, + onBackClick = onBackClick + ) Column( modifier = Modifier @@ -137,50 +156,17 @@ private fun VideoQualityScreen( .verticalScroll(rememberScrollState()), horizontalAlignment = Alignment.CenterHorizontally ) { - val autoQuality = - stringResource(id = R.string.auto_recommended_text).split(Regex("\\s"), 2) - QualityOption( - title = autoQuality[0], - description = autoQuality[1], - selected = videoQuality == VideoQuality.AUTO, - onClick = { - onQualityChanged(VideoQuality.AUTO) - } - ) - Divider() - val option360p = - stringResource(id = R.string.video_quality_p360).split(Regex("\\s"), 2) - QualityOption( - title = option360p[0], - description = option360p[1], - selected = videoQuality == VideoQuality.OPTION_360P, - onClick = { - onQualityChanged(VideoQuality.OPTION_360P) - } - ) - Divider() - val option540p = - stringResource(id = R.string.video_quality_p540) - QualityOption( - title = option540p, - description = "", - selected = videoQuality == VideoQuality.OPTION_540P, - onClick = { - onQualityChanged(VideoQuality.OPTION_540P) - } - ) - Divider() - val option720p = - stringResource(id = R.string.video_quality_p720).split(Regex("\\s"), 2) - QualityOption( - title = option720p[0], - description = option720p[1], - selected = videoQuality == VideoQuality.OPTION_720P, - onClick = { - onQualityChanged(VideoQuality.OPTION_720P) - } - ) - Divider() + VideoQuality.values().forEach { videoQuality -> + QualityOption( + title = stringResource(id = videoQuality.titleResId), + description = videoQuality.desResId.nonZero() + ?.let { stringResource(id = videoQuality.desResId) } ?: "", + selected = selectedVideoQuality == videoQuality, + onClick = { + onQualityChanged(videoQuality) + } + ) + } } } } @@ -190,12 +176,13 @@ private fun VideoQualityScreen( @Composable private fun QualityOption( title: String, - description: String?, + description: String, selected: Boolean, onClick: () -> Unit ) { Row( Modifier + .testTag("btn_video_quality_${title.tagId()}") .fillMaxWidth() .height(90.dp) .clickable { @@ -209,14 +196,16 @@ private fun QualityOption( verticalArrangement = Arrangement.Center ) { Text( + modifier = Modifier.testTag("txt_video_quality_title_${title.tagId()}"), text = title, color = MaterialTheme.appColors.textPrimary, style = MaterialTheme.appTypography.titleMedium ) - if (!description.isNullOrEmpty()) { + if (description.isNotEmpty()) { Spacer(Modifier.height(4.dp)) Text( - text = description.replace(Regex("[(|)]"), ""), + modifier = Modifier.testTag("txt_video_quality_description_${title.tagId()}"), + text = description, color = MaterialTheme.appColors.textSecondary, style = MaterialTheme.appTypography.labelMedium ) @@ -224,13 +213,14 @@ private fun QualityOption( } if (selected) { Icon( + modifier = Modifier.testTag("ic_video_quality_selected_${title.tagId()}"), imageVector = Icons.Filled.Done, tint = MaterialTheme.appColors.primary, contentDescription = null ) } } - + Divider() } @Preview(uiMode = UI_MODE_NIGHT_NO) @@ -240,8 +230,8 @@ private fun VideoQualityScreenPreview() { OpenEdXTheme { VideoQualityScreen( windowSize = WindowSize(WindowType.Compact, WindowType.Compact), - videoQuality = VideoQuality.OPTION_720P, + selectedVideoQuality = VideoQuality.OPTION_720P, onQualityChanged = {}, onBackClick = {}) } -} \ No newline at end of file +} 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 747df792a..42ecf16f6 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 @@ -6,31 +6,61 @@ import android.os.Bundle import android.view.LayoutInflater import android.view.ViewGroup import androidx.compose.foundation.clickable -import androidx.compose.foundation.layout.* -import androidx.compose.material.* +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.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.Divider +import androidx.compose.material.Icon +import androidx.compose.material.MaterialTheme +import androidx.compose.material.Scaffold +import androidx.compose.material.Switch +import androidx.compose.material.SwitchDefaults +import androidx.compose.material.Text import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.ChevronRight -import androidx.compose.runtime.* +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.platform.ComposeView import androidx.compose.ui.platform.ViewCompositionStrategy +import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.semantics.testTagsAsResourceId 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.android.ext.android.inject +import org.koin.androidx.viewmodel.ext.android.viewModel import org.openedx.core.domain.model.VideoSettings -import org.openedx.core.ui.* +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.noRippleClickable +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.koin.android.ext.android.inject -import org.koin.androidx.viewmodel.ext.android.viewModel import org.openedx.profile.R as profileR class VideoSettingsFragment : Fragment() { @@ -76,6 +106,7 @@ class VideoSettingsFragment : Fragment() { } +@OptIn(ExperimentalComposeUiApi::class) @Composable private fun VideoSettingsScreen( windowSize: WindowSize, @@ -91,7 +122,11 @@ private fun VideoSettingsScreen( } Scaffold( - modifier = Modifier.fillMaxSize(), + modifier = Modifier + .fillMaxSize() + .semantics { + testTagsAsResourceId = true + }, scaffoldState = scaffoldState ) { paddingValues -> @@ -127,23 +162,12 @@ private fun VideoSettingsScreen( .displayCutoutForLandscape(), horizontalAlignment = Alignment.CenterHorizontally ) { - Box( + Toolbar( modifier = topBarWidth, - contentAlignment = Alignment.CenterStart - ) { - Text( - modifier = Modifier - .fillMaxWidth(), - text = stringResource(id = org.openedx.profile.R.string.profile_video_settings), - color = MaterialTheme.appColors.textPrimary, - textAlign = TextAlign.Center, - style = MaterialTheme.appTypography.titleMedium - ) - - BackBtn(modifier = Modifier.padding(start = 8.dp)) { - onBackClick() - } - } + label = stringResource(id = org.openedx.profile.R.string.profile_video_settings), + canShowBackBtn = true, + onBackClick = onBackClick + ) Column( modifier = Modifier.then(contentWidth), @@ -151,6 +175,7 @@ private fun VideoSettingsScreen( ) { Row( Modifier + .testTag("btn_wifi_only") .fillMaxWidth() .height(92.dp) .noRippleClickable { @@ -162,18 +187,21 @@ private fun VideoSettingsScreen( ) { Column(Modifier.weight(1f)) { Text( + modifier = Modifier.testTag("txt_wifi_only_label"), text = stringResource(id = profileR.string.profile_wifi_only_download), color = MaterialTheme.appColors.textPrimary, style = MaterialTheme.appTypography.titleMedium ) Spacer(Modifier.height(4.dp)) Text( + modifier = Modifier.testTag("txt_wifi_only_description"), text = stringResource(id = profileR.string.profile_only_download_when_wifi_turned_on), color = MaterialTheme.appColors.textSecondary, style = MaterialTheme.appTypography.labelMedium ) } Switch( + modifier = Modifier.testTag("sw_wifi_only"), checked = wifiDownloadOnly, onCheckedChange = { wifiDownloadOnly = !wifiDownloadOnly @@ -188,6 +216,7 @@ private fun VideoSettingsScreen( Divider() Row( Modifier + .testTag("btn_video_quality") .fillMaxWidth() .height(92.dp) .clickable { @@ -198,12 +227,14 @@ private fun VideoSettingsScreen( ) { Column(Modifier.weight(1f)) { Text( + modifier = Modifier.testTag("txt_video_quality_label"), text = stringResource(id = profileR.string.profile_video_streaming_quality), color = MaterialTheme.appColors.textPrimary, style = MaterialTheme.appTypography.titleMedium ) Spacer(Modifier.height(4.dp)) Text( + modifier = Modifier.testTag("txt_video_quality_description"), text = stringResource(id = videoSettings.videoQuality.titleResId), color = MaterialTheme.appColors.textSecondary, style = MaterialTheme.appTypography.labelMedium @@ -235,4 +266,4 @@ private fun VideoSettingsScreenPreview() { videoSettings = VideoSettings.default ) } -} \ No newline at end of file +} 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 index 9dceab592..c47820f23 100644 --- a/profile/src/main/java/org/openedx/profile/presentation/ui/ProfileUI.kt +++ b/profile/src/main/java/org/openedx/profile/presentation/ui/ProfileUI.kt @@ -1,5 +1,6 @@ package org.openedx.profile.presentation.ui +import android.content.res.Configuration import androidx.compose.foundation.border import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column @@ -18,9 +19,11 @@ 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.platform.testTag import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.SpanStyle import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import coil.compose.AsyncImage import coil.request.ImageRequest @@ -29,6 +32,7 @@ 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 +import org.openedx.profile.presentation.profile.compose.mockAccount @Composable fun ProfileTopic(account: Account) { @@ -47,8 +51,12 @@ fun ProfileTopic(account: Account) { .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), + contentDescription = stringResource( + id = R.string.core_accessibility_user_profile_image, + account.username + ), modifier = Modifier + .testTag("img_profile") .border( 2.dp, MaterialTheme.appColors.onSurface, @@ -61,6 +69,7 @@ fun ProfileTopic(account: Account) { if (account.name.isNotEmpty()) { Spacer(modifier = Modifier.height(20.dp)) Text( + modifier = Modifier.testTag("txt_profile_name"), text = account.name, color = MaterialTheme.appColors.textPrimary, style = MaterialTheme.appTypography.headlineSmall @@ -68,6 +77,7 @@ fun ProfileTopic(account: Account) { } Spacer(modifier = Modifier.height(4.dp)) Text( + modifier = Modifier.testTag("txt_profile_username"), text = "@${account.username}", color = MaterialTheme.appColors.textPrimaryVariant, style = MaterialTheme.appTypography.labelLarge @@ -81,6 +91,7 @@ fun ProfileInfoSection(account: Account) { if (account.yearOfBirth != null || account.bio.isNotEmpty()) { Column { Text( + modifier = Modifier.testTag("txt_profile_info_label"), text = stringResource(id = org.openedx.profile.R.string.profile_prof_info), style = MaterialTheme.appTypography.labelLarge, color = MaterialTheme.appColors.textSecondary @@ -100,6 +111,7 @@ fun ProfileInfoSection(account: Account) { ) { if (account.yearOfBirth != null) { Text( + modifier = Modifier.testTag("txt_profile_year_of_birth"), text = buildAnnotatedString { val value = if (account.yearOfBirth != null) { account.yearOfBirth.toString() @@ -123,6 +135,7 @@ fun ProfileInfoSection(account: Account) { } if (account.bio.isNotEmpty()) { Text( + modifier = Modifier.testTag("txt_profile_bio"), text = buildAnnotatedString { val text = stringResource( id = org.openedx.profile.R.string.profile_bio, @@ -145,4 +158,22 @@ fun ProfileInfoSection(account: Account) { } } } -} \ No newline at end of file +} + +@Preview(uiMode = Configuration.UI_MODE_NIGHT_NO) +@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES) +@Composable +fun ProfileTopicPreview() { + ProfileTopic( + account = mockAccount + ) +} + +@Preview(uiMode = Configuration.UI_MODE_NIGHT_NO) +@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES) +@Composable +fun ProfileInfoSectionPreview() { + ProfileInfoSection( + account = mockAccount + ) +} From 46a5a583d2ebe954691c32864f191c9c8fef1614 Mon Sep 17 00:00:00 2001 From: Volodymyr Chekyrta Date: Wed, 21 Feb 2024 13:57:03 +0200 Subject: [PATCH 06/39] refactor: Feedback addressed --- app/.gitignore | 3 ++- app/build.gradle | 6 ++++++ app/src/main/java/org/openedx/app/AnalyticsManager.kt | 2 +- .../src/main/groovy/org/edx/builder/ConfigHelper.groovy | 8 ++++++++ 4 files changed, 17 insertions(+), 2 deletions(-) diff --git a/app/.gitignore b/app/.gitignore index 42afabfd2..2abde4aab 100644 --- a/app/.gitignore +++ b/app/.gitignore @@ -1 +1,2 @@ -/build \ No newline at end of file +/build +/google-services.json diff --git a/app/build.gradle b/app/build.gradle index 726ff3377..645059efb 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -17,6 +17,12 @@ if (firebaseEnabled) { } preBuild.dependsOn(generateGoogleServicesJson) +} else { + tasks.register('removeGoogleServicesJson') { + configHelper.removeGoogleServicesJson() + } + + preBuild.dependsOn(removeGoogleServicesJson) } android { diff --git a/app/src/main/java/org/openedx/app/AnalyticsManager.kt b/app/src/main/java/org/openedx/app/AnalyticsManager.kt index d2604ca1d..e474416dd 100644 --- a/app/src/main/java/org/openedx/app/AnalyticsManager.kt +++ b/app/src/main/java/org/openedx/app/AnalyticsManager.kt @@ -22,7 +22,7 @@ class AnalyticsManager( init { // Initialise all the analytics libraries here - if (config.getFirebaseConfig().projectId.isNotBlank()) { + if (config.getFirebaseConfig().enabled) { addAnalyticsTracker(FirebaseAnalytics(context = context)) } val segmentConfig = config.getSegmentConfig() diff --git a/buildSrc/src/main/groovy/org/edx/builder/ConfigHelper.groovy b/buildSrc/src/main/groovy/org/edx/builder/ConfigHelper.groovy index 32f1fb678..10ca3e8db 100644 --- a/buildSrc/src/main/groovy/org/edx/builder/ConfigHelper.groovy +++ b/buildSrc/src/main/groovy/org/edx/builder/ConfigHelper.groovy @@ -140,4 +140,12 @@ class ConfigHelper { it.write(new JsonBuilder(configJson).toPrettyString()) } } + + def removeGoogleServicesJson() { + def googleServicesJsonPath = projectDir.path + "/app/google-services.json" + def file = new File(googleServicesJsonPath) + if (file.exists()) { + file.delete() + } + } } From 83af60f88bc126a651bddc5f15632d2c068eda76 Mon Sep 17 00:00:00 2001 From: Volodymyr Chekyrta Date: Wed, 21 Feb 2024 14:25:24 +0200 Subject: [PATCH 07/39] refactor: Removed unused fields --- default_config/dev/config.yaml | 1 - default_config/prod/config.yaml | 1 - default_config/stage/config.yaml | 1 - 3 files changed, 3 deletions(-) diff --git a/default_config/dev/config.yaml b/default_config/dev/config.yaml index 8c940ef66..ae255c430 100644 --- a/default_config/dev/config.yaml +++ b/default_config/dev/config.yaml @@ -36,7 +36,6 @@ FIREBASE: PROJECT_ID: '' APPLICATION_ID: '' API_KEY: '' - GCM_SENDER_ID: '' SEGMENT_IO: ENABLED: false diff --git a/default_config/prod/config.yaml b/default_config/prod/config.yaml index cdaa040a0..804e4a45f 100644 --- a/default_config/prod/config.yaml +++ b/default_config/prod/config.yaml @@ -36,7 +36,6 @@ FIREBASE: PROJECT_ID: '' APPLICATION_ID: '' API_KEY: '' - GCM_SENDER_ID: '' SEGMENT_IO: ENABLED: false diff --git a/default_config/stage/config.yaml b/default_config/stage/config.yaml index cdaa040a0..804e4a45f 100644 --- a/default_config/stage/config.yaml +++ b/default_config/stage/config.yaml @@ -36,7 +36,6 @@ FIREBASE: PROJECT_ID: '' APPLICATION_ID: '' API_KEY: '' - GCM_SENDER_ID: '' SEGMENT_IO: ENABLED: false From b09a621952a1b1815175c8444def545fe22e3d3a Mon Sep 17 00:00:00 2001 From: Volodymyr Chekyrta Date: Fri, 23 Feb 2024 11:08:22 +0200 Subject: [PATCH 08/39] refactor: Added comments to Firebase configuration --- default_config/dev/config.yaml | 2 +- default_config/prod/config.yaml | 2 +- default_config/stage/config.yaml | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/default_config/dev/config.yaml b/default_config/dev/config.yaml index ae255c430..09c746827 100644 --- a/default_config/dev/config.yaml +++ b/default_config/dev/config.yaml @@ -34,7 +34,7 @@ FIREBASE: CLOUD_MESSAGING_ENABLED: false PROJECT_NUMBER: '' PROJECT_ID: '' - APPLICATION_ID: '' + APPLICATION_ID: '' #App ID field from the Firebase console or mobilesdk_app_id from the google-services.json file. API_KEY: '' SEGMENT_IO: diff --git a/default_config/prod/config.yaml b/default_config/prod/config.yaml index 804e4a45f..3cc0b6592 100644 --- a/default_config/prod/config.yaml +++ b/default_config/prod/config.yaml @@ -34,7 +34,7 @@ FIREBASE: CLOUD_MESSAGING_ENABLED: false PROJECT_NUMBER: '' PROJECT_ID: '' - APPLICATION_ID: '' + APPLICATION_ID: '' #App ID field from the Firebase console or mobilesdk_app_id from the google-services.json file. API_KEY: '' SEGMENT_IO: diff --git a/default_config/stage/config.yaml b/default_config/stage/config.yaml index 804e4a45f..3cc0b6592 100644 --- a/default_config/stage/config.yaml +++ b/default_config/stage/config.yaml @@ -34,7 +34,7 @@ FIREBASE: CLOUD_MESSAGING_ENABLED: false PROJECT_NUMBER: '' PROJECT_ID: '' - APPLICATION_ID: '' + APPLICATION_ID: '' #App ID field from the Firebase console or mobilesdk_app_id from the google-services.json file. API_KEY: '' SEGMENT_IO: From 8f6aaf487fafd0388db540d5d9fd07e945714b01 Mon Sep 17 00:00:00 2001 From: Igor Date: Sun, 25 Feb 2024 16:57:18 +0100 Subject: [PATCH 09/39] Feat: Download all videos together (#234) * feat: ability to download all videos * feat: confirmation dialog for downloading videos larger than 1 GB * refactor: renamed the Video tab to Videos * refactor: hide All videos download element if there is no videos to download * fix: bug when unable to see all videos * fix: lags when updating downloading state * refactor: changed the type of allBlocks to HashMap in the BaseDownloadViewModel * feat: confirmation dialog when trying to remove all downloads * feat: show download progress on the download queue screen * feat: view downloads for subsection * refactor: changed how all modules are download * refactor: optimized way to remove models * refactor: changed the way the download progress is displayed * feat: added confirmation dialogs * feat: show Untitled title if the block has no title * refactor: removed unused logs * fix: after rebase * fix: after rebase * refactor: change the name of the Discussion tab to Discussions * fix: fixed issues after PR --- app/src/main/AndroidManifest.xml | 1 + .../main/java/org/openedx/app/AppRouter.kt | 12 +- .../app/data/storage/PreferencesManager.kt | 32 +- .../main/java/org/openedx/app/di/AppModule.kt | 4 + .../java/org/openedx/app/di/ScreenModule.kt | 16 +- .../java/org/openedx/core/AppDataConstants.kt | 5 +- .../core/domain/model/VideoSettings.kt | 5 +- .../org/openedx/core/extension/LongExt.kt | 18 + .../org/openedx/core/module/DownloadWorker.kt | 26 +- .../core/module/DownloadWorkerController.kt | 70 +- .../org/openedx/core/module/db/DownloadDao.kt | 9 +- .../module/download/BaseDownloadViewModel.kt | 199 +++-- .../module/download/DownloadModelsSize.kt | 10 + .../core/module/download/FileDownloader.kt | 6 +- .../settings}/VideoQualityFragment.kt | 49 +- .../presentation/settings/VideoQualityType.kt | 5 + .../settings/VideoQualityViewModel.kt | 47 + .../core/system/notifier/DownloadEvent.kt | 3 + .../core/system/notifier/DownloadNotifier.kt | 15 + .../notifier/DownloadProgressChanged.kt | 5 + .../core/system/notifier/VideoEvent.kt | 3 + .../core/system/notifier/VideoNotifier.kt | 14 + .../system/notifier/VideoQualityChanged.kt | 3 + core/src/main/res/values-uk/strings.xml | 2 + core/src/main/res/values/strings.xml | 13 +- .../domain/interactor/CourseInteractor.kt | 12 +- .../course/presentation/CourseRouter.kt | 5 + .../container/CourseContainerAdapter.kt | 4 +- .../container/CourseContainerFragment.kt | 1 + .../outline/CourseOutlineFragment.kt | 7 +- .../section/CourseSectionFragment.kt | 6 +- .../course/presentation/ui/CourseUI.kt | 110 ++- .../course/presentation/ui/CourseVideosUI.kt | 827 ++++++++++++++++++ .../unit/video/EncodedVideoUnitViewModel.kt | 4 +- .../presentation/unit/video/VideoViewModel.kt | 4 +- .../videos/CourseVideoViewModel.kt | 71 +- .../videos/CourseVideosFragment.kt | 449 +--------- .../videos/CourseVideosUIState.kt | 6 +- .../download/DownloadQueueFragment.kt | 254 ++++++ .../settings/download/DownloadQueueUIState.kt | 16 + .../download/DownloadQueueViewModel.kt | 72 ++ .../res/menu/bottom_course_container_menu.xml | 6 +- course/src/main/res/values-uk/strings.xml | 4 +- course/src/main/res/values/strings.xml | 15 +- .../outline/CourseOutlineViewModelTest.kt | 8 +- .../section/CourseSectionViewModelTest.kt | 14 +- .../videos/CourseVideoViewModelTest.kt | 75 +- .../profile/presentation/ProfileRouter.kt | 3 +- .../delete/DeleteProfileViewModel.kt | 2 +- .../presentation/edit/EditProfileViewModel.kt | 2 +- .../presentation/profile/ProfileViewModel.kt | 2 +- .../settings/video/VideoQualityViewModel.kt | 37 - .../settings/video/VideoSettingsFragment.kt | 54 +- .../settings/video/VideoSettingsViewModel.kt | 12 +- .../system/notifier/AccountDeactivated.kt | 2 +- .../profile/system/notifier/AccountUpdated.kt | 2 +- .../profile/system/notifier/ProfileEvent.kt | 3 +- .../system/notifier/ProfileNotifier.kt | 4 +- .../system/notifier/VideoQualityChanged.kt | 3 - profile/src/main/res/values-uk/strings.xml | 3 +- profile/src/main/res/values/strings.xml | 3 +- .../edit/EditProfileViewModelTest.kt | 2 +- .../profile/ProfileViewModelTest.kt | 6 +- 63 files changed, 1994 insertions(+), 688 deletions(-) create mode 100644 core/src/main/java/org/openedx/core/extension/LongExt.kt create mode 100644 core/src/main/java/org/openedx/core/module/download/DownloadModelsSize.kt rename {profile/src/main/java/org/openedx/profile/presentation/settings/video => core/src/main/java/org/openedx/core/presentation/settings}/VideoQualityFragment.kt (85%) create mode 100644 core/src/main/java/org/openedx/core/presentation/settings/VideoQualityType.kt create mode 100644 core/src/main/java/org/openedx/core/presentation/settings/VideoQualityViewModel.kt create mode 100644 core/src/main/java/org/openedx/core/system/notifier/DownloadEvent.kt create mode 100644 core/src/main/java/org/openedx/core/system/notifier/DownloadNotifier.kt create mode 100644 core/src/main/java/org/openedx/core/system/notifier/DownloadProgressChanged.kt create mode 100644 core/src/main/java/org/openedx/core/system/notifier/VideoEvent.kt create mode 100644 core/src/main/java/org/openedx/core/system/notifier/VideoNotifier.kt create mode 100644 core/src/main/java/org/openedx/core/system/notifier/VideoQualityChanged.kt create mode 100644 course/src/main/java/org/openedx/course/presentation/ui/CourseVideosUI.kt create mode 100644 course/src/main/java/org/openedx/course/settings/download/DownloadQueueFragment.kt create mode 100644 course/src/main/java/org/openedx/course/settings/download/DownloadQueueUIState.kt create mode 100644 course/src/main/java/org/openedx/course/settings/download/DownloadQueueViewModel.kt delete mode 100644 profile/src/main/java/org/openedx/profile/presentation/settings/video/VideoQualityViewModel.kt delete mode 100644 profile/src/main/java/org/openedx/profile/system/notifier/VideoQualityChanged.kt diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index b5581c340..e3af19210 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -5,6 +5,7 @@ + diff --git a/app/src/main/java/org/openedx/app/AppRouter.kt b/app/src/main/java/org/openedx/app/AppRouter.kt index eff44c9a7..daf3662f0 100644 --- a/app/src/main/java/org/openedx/app/AppRouter.kt +++ b/app/src/main/java/org/openedx/app/AppRouter.kt @@ -13,6 +13,8 @@ import org.openedx.core.presentation.course.CourseViewMode import org.openedx.core.presentation.global.app_upgrade.AppUpgradeRouter import org.openedx.core.presentation.global.app_upgrade.UpgradeRequiredFragment import org.openedx.core.presentation.global.webview.WebContentFragment +import org.openedx.core.presentation.settings.VideoQualityFragment +import org.openedx.core.presentation.settings.VideoQualityType import org.openedx.course.presentation.CourseRouter import org.openedx.course.presentation.container.CourseContainerFragment import org.openedx.course.presentation.container.NoAccessCourseContainerFragment @@ -24,6 +26,7 @@ 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.course.settings.download.DownloadQueueFragment import org.openedx.dashboard.presentation.DashboardRouter import org.openedx.dashboard.presentation.program.ProgramFragment import org.openedx.discovery.presentation.DiscoveryRouter @@ -44,7 +47,6 @@ 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 org.openedx.whatsnew.WhatsNewRouter import org.openedx.whatsnew.presentation.whatsnew.WhatsNewFragment @@ -72,6 +74,10 @@ class AppRouter : AuthRouter, DiscoveryRouter, DashboardRouter, CourseRouter, Di replaceFragmentWithBackStack(fm, LogistrationFragment.newInstance(courseId)) } + override fun navigateToDownloadQueue(fm: FragmentManager, descendants: List) { + replaceFragmentWithBackStack(fm, DownloadQueueFragment.newInstance(descendants)) + } + override fun navigateToRestorePassword(fm: FragmentManager) { replaceFragmentWithBackStack(fm, RestorePasswordFragment()) } @@ -325,8 +331,8 @@ class AppRouter : AuthRouter, DiscoveryRouter, DashboardRouter, CourseRouter, Di replaceFragmentWithBackStack(fm, VideoSettingsFragment()) } - override fun navigateToVideoQuality(fm: FragmentManager) { - replaceFragmentWithBackStack(fm, VideoQualityFragment()) + override fun navigateToVideoQuality(fm: FragmentManager, videoQualityType: VideoQualityType) { + replaceFragmentWithBackStack(fm, VideoQualityFragment.newInstance(videoQualityType.name)) } override fun navigateToDeleteAccount(fm: FragmentManager) { 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 bd7eb17e5..eeeccd39c 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 @@ -6,6 +6,7 @@ import org.openedx.app.BuildConfig import org.openedx.core.data.model.User import org.openedx.core.data.storage.CorePreferences import org.openedx.core.data.storage.InAppReviewPreferences +import org.openedx.core.domain.model.VideoQuality import org.openedx.core.domain.model.VideoSettings import org.openedx.profile.data.model.Account import org.openedx.profile.data.storage.ProfilePreferences @@ -23,7 +24,9 @@ class PreferencesManager(context: Context) : CorePreferences, ProfilePreferences }.apply() } - private fun getString(key: String): String = sharedPreferences.getString(key, "") ?: "" + private fun getString(key: String, defValue: String = ""): String { + return sharedPreferences.getString(key, defValue) ?: defValue + } private fun saveLong(key: String, value: Long) { sharedPreferences.edit().apply { @@ -39,7 +42,9 @@ class PreferencesManager(context: Context) : CorePreferences, ProfilePreferences }.apply() } - private fun getBoolean(key: String): Boolean = sharedPreferences.getBoolean(key, false) + private fun getBoolean(key: String, defValue: Boolean = false): Boolean { + return sharedPreferences.getBoolean(key, defValue) + } override fun clear() { sharedPreferences.edit().apply { @@ -90,13 +95,22 @@ class PreferencesManager(context: Context) : CorePreferences, ProfilePreferences override var videoSettings: VideoSettings set(value) { - val videoSettingsJson = Gson().toJson(value) - saveString(VIDEO_SETTINGS, videoSettingsJson) + saveBoolean(VIDEO_SETTINGS_WIFI_DOWNLOAD_ONLY, value.wifiDownloadOnly) + saveString(VIDEO_SETTINGS_STREAMING_QUALITY, value.videoStreamingQuality.name) + saveString(VIDEO_SETTINGS_DOWNLOAD_QUALITY, value.videoDownloadQuality.name) } get() { - val videoSettingsString = getString(VIDEO_SETTINGS) - return Gson().fromJson(videoSettingsString, VideoSettings::class.java) - ?: VideoSettings.default + val wifiDownloadOnly = getBoolean(VIDEO_SETTINGS_WIFI_DOWNLOAD_ONLY, defValue = true) + val streamingQualityString = + getString(VIDEO_SETTINGS_STREAMING_QUALITY, defValue = VideoQuality.AUTO.name) + val downloadQualityString = + getString(VIDEO_SETTINGS_DOWNLOAD_QUALITY, defValue = VideoQuality.AUTO.name) + + return VideoSettings( + wifiDownloadOnly = wifiDownloadOnly, + videoStreamingQuality = VideoQuality.valueOf(streamingQualityString), + videoDownloadQuality = VideoQuality.valueOf(downloadQualityString) + ) } override var lastWhatsNewVersion: String @@ -132,9 +146,11 @@ class PreferencesManager(context: Context) : CorePreferences, ProfilePreferences 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" + private const val VIDEO_SETTINGS_WIFI_DOWNLOAD_ONLY = "video_settings_wifi_download_only" + private const val VIDEO_SETTINGS_STREAMING_QUALITY = "video_settings_streaming_quality" + private const val VIDEO_SETTINGS_DOWNLOAD_QUALITY = "video_settings_download_quality" } } 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 c5a267ece..dba5727d5 100644 --- a/app/src/main/java/org/openedx/app/di/AppModule.kt +++ b/app/src/main/java/org/openedx/app/di/AppModule.kt @@ -42,6 +42,8 @@ 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.core.system.notifier.DownloadNotifier +import org.openedx.core.system.notifier.VideoNotifier import org.openedx.course.presentation.CourseAnalytics import org.openedx.course.presentation.CourseRouter import org.openedx.dashboard.presentation.dashboard.DashboardAnalytics @@ -79,7 +81,9 @@ val appModule = module { single { DiscussionNotifier() } single { ProfileNotifier() } single { AppUpgradeNotifier() } + single { DownloadNotifier() } single { DashboardNotifier() } + single { VideoNotifier() } single { AppRouter() } single { get() } 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 2f86dbb5a..8391a0e03 100644 --- a/app/src/main/java/org/openedx/app/di/ScreenModule.kt +++ b/app/src/main/java/org/openedx/app/di/ScreenModule.kt @@ -12,6 +12,7 @@ import org.openedx.auth.presentation.signin.SignInViewModel import org.openedx.auth.presentation.signup.SignUpViewModel import org.openedx.core.Validator import org.openedx.core.presentation.dialog.selectorbottomsheet.SelectDialogViewModel +import org.openedx.core.presentation.settings.VideoQualityViewModel import org.openedx.course.data.repository.CourseRepository import org.openedx.course.domain.interactor.CourseInteractor import org.openedx.course.presentation.container.CourseContainerViewModel @@ -27,6 +28,7 @@ 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.course.settings.download.DownloadQueueViewModel import org.openedx.dashboard.data.repository.DashboardRepository import org.openedx.dashboard.domain.interactor.DashboardInteractor import org.openedx.dashboard.presentation.dashboard.DashboardViewModel @@ -52,7 +54,6 @@ import org.openedx.profile.presentation.anothers_account.AnothersProfileViewMode 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.openedx.whatsnew.presentation.whatsnew.WhatsNewViewModel @@ -120,7 +121,7 @@ val screenModule = module { } viewModel { (account: Account) -> EditProfileViewModel(get(), get(), get(), get(), account) } viewModel { VideoSettingsViewModel(get(), get()) } - viewModel { VideoQualityViewModel(get(), get()) } + viewModel { (qualityType: String) -> VideoQualityViewModel(get(), get(), qualityType) } viewModel { DeleteProfileViewModel(get(), get(), get(), get()) } viewModel { (username: String) -> AnothersProfileViewModel(get(), get(), username) } @@ -210,6 +211,7 @@ val screenModule = module { get(), get(), get(), + get(), get() ) } @@ -293,6 +295,16 @@ val screenModule = module { get() ) } + + viewModel { (descendants: List) -> + DownloadQueueViewModel( + descendants, + get(), + get(), + get(), + get() + ) + } viewModel { HtmlUnitViewModel(get(), get(), get(), get()) } viewModel { ProgramViewModel(get(), get(), get(), get(), get(), get(), get()) } diff --git a/core/src/main/java/org/openedx/core/AppDataConstants.kt b/core/src/main/java/org/openedx/core/AppDataConstants.kt index 0bb5a95d0..eb2580e99 100644 --- a/core/src/main/java/org/openedx/core/AppDataConstants.kt +++ b/core/src/main/java/org/openedx/core/AppDataConstants.kt @@ -10,4 +10,7 @@ object AppDataConstants { const val VIDEO_FORMAT_M3U8 = ".m3u8" const val VIDEO_FORMAT_MP4 = ".mp4" -} \ No newline at end of file + + // Equal 1GB + const val DOWNLOADS_CONFIRMATION_SIZE = 1024 * 1024 * 1024L +} 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 eb9d6309b..ec6391fe4 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 @@ -4,10 +4,11 @@ import org.openedx.core.R data class VideoSettings( val wifiDownloadOnly: Boolean, - val videoQuality: VideoQuality + val videoStreamingQuality: VideoQuality, + val videoDownloadQuality: VideoQuality ) { companion object { - val default = VideoSettings(true, VideoQuality.AUTO) + val default = VideoSettings(true, VideoQuality.AUTO, VideoQuality.AUTO) } } diff --git a/core/src/main/java/org/openedx/core/extension/LongExt.kt b/core/src/main/java/org/openedx/core/extension/LongExt.kt new file mode 100644 index 000000000..06f052616 --- /dev/null +++ b/core/src/main/java/org/openedx/core/extension/LongExt.kt @@ -0,0 +1,18 @@ +package org.openedx.core.extension + +import kotlin.math.log10 +import kotlin.math.pow + +fun Long.toFileSize(round: Int = 2): String { + try { + if (this <= 0) return "0" + val units = arrayOf("B", "KB", "MB", "GB", "TB", "PB", "EB", "ZB", "YB") + val digitGroups = (log10(this.toDouble()) / log10(1024.0)).toInt() + return String.format( + "%." + round + "f", this / 1024.0.pow(digitGroups.toDouble()) + ) + " " + units[digitGroups] + } catch (e: Exception) { + println(e.toString()) + } + return "" +} 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 551d5b823..9234ec023 100644 --- a/core/src/main/java/org/openedx/core/module/DownloadWorker.kt +++ b/core/src/main/java/org/openedx/core/module/DownloadWorker.kt @@ -10,7 +10,9 @@ import androidx.core.app.NotificationCompat import androidx.work.CoroutineWorker import androidx.work.ForegroundInfo import androidx.work.WorkerParameters +import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.first +import kotlinx.coroutines.launch import org.koin.java.KoinJavaComponent.inject import org.openedx.core.R import org.openedx.core.module.db.DownloadDao @@ -19,12 +21,14 @@ 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 org.openedx.core.system.notifier.DownloadNotifier +import org.openedx.core.system.notifier.DownloadProgressChanged import java.io.File class DownloadWorker( val context: Context, parameters: WorkerParameters -) : CoroutineWorker(context, parameters) { +) : CoroutineWorker(context, parameters), CoroutineScope { private val notificationManager = context.getSystemService(Context.NOTIFICATION_SERVICE) as @@ -32,6 +36,7 @@ class DownloadWorker( private val notificationBuilder = NotificationCompat.Builder(context, CHANNEL_ID) + private val notifier by inject(DownloadNotifier::class.java) private val downloadDao: DownloadDao by inject(DownloadDao::class.java) private var downloadEnqueue = listOf() @@ -43,6 +48,7 @@ class DownloadWorker( ) private var currentDownload: DownloadModel? = null + private var lastUpdateTime = 0L private val fileDownloader by inject(FileDownloader::class.java) @@ -79,14 +85,24 @@ class DownloadWorker( private fun updateProgress() { fileDownloader.progressListener = object : CurrentProgress { - override fun progress(value: Long) { - if (!fileDownloader.isCanceled) { + override fun progress(value: Long, size: Long) { + val progress = 100 * value / size + // Update no more than 5 times per sec + if (!fileDownloader.isCanceled && + (System.currentTimeMillis() - lastUpdateTime > 200) + ) { + lastUpdateTime = System.currentTimeMillis() + currentDownload?.let { + launch { + notifier.send(DownloadProgressChanged(it.id, value, size)) + } + notificationManager.notify( NOTIFICATION_ID, notificationBuilder .setSmallIcon(R.drawable.core_ic_check_in_box) - .setProgress(100, value.toInt(), false) + .setProgress(100, progress.toInt(), false) .setPriority(NotificationManager.IMPORTANCE_LOW) .setContentText(context.getString(R.string.core_downloading_in_progress)) .setContentTitle(it.title) @@ -152,4 +168,4 @@ class DownloadWorker( private const val NOTIFICATION_ID = 10 } -} \ No newline at end of file +} diff --git a/core/src/main/java/org/openedx/core/module/DownloadWorkerController.kt b/core/src/main/java/org/openedx/core/module/DownloadWorkerController.kt index 54bdd6dde..a4e83c07e 100644 --- a/core/src/main/java/org/openedx/core/module/DownloadWorkerController.kt +++ b/core/src/main/java/org/openedx/core/module/DownloadWorkerController.kt @@ -5,14 +5,15 @@ import androidx.work.OneTimeWorkRequestBuilder import androidx.work.WorkInfo import androidx.work.WorkManager import com.google.common.util.concurrent.ListenableFuture +import kotlinx.coroutines.GlobalScope +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.launch import org.openedx.core.module.db.DownloadDao import org.openedx.core.module.db.DownloadModel import org.openedx.core.module.db.DownloadModelEntity import org.openedx.core.module.db.DownloadedState import org.openedx.core.module.download.FileDownloader -import kotlinx.coroutines.GlobalScope -import kotlinx.coroutines.flow.first -import kotlinx.coroutines.launch +import java.io.File import java.util.concurrent.ExecutionException class DownloadWorkerController( @@ -51,34 +52,46 @@ class DownloadWorkerController( } } - suspend fun saveModels(vararg downloadModel: DownloadModel) { - downloadDao.insertDownloadModel( - *downloadModel.map { DownloadModelEntity.createFrom(it) }.toTypedArray() - ) + suspend fun saveModels(downloadModels: List) { + downloadDao.insertDownloadModel( + downloadModels.map { DownloadModelEntity.createFrom(it) } + ) } - suspend fun cancelWork(vararg ids: String) { - for (id in ids.toList()) { - updateList() - val downloadModel = downloadTaskList.find { it.id == id } - if (downloadTaskList.size == 1) { - fileDownloader.cancelDownloading() - downloadDao.removeDownloadModel(id) - workManager.cancelAllWorkByTag(DownloadWorker.WORKER_TAG) - return - } - downloadModel?.let { - if (it.downloadedState == DownloadedState.WAITING) { - downloadDao.removeDownloadModel(id) - } else { - fileDownloader.cancelDownloading() - downloadDao.removeDownloadModel(id) - } - } + suspend fun removeModel(id: String) { + removeModels(listOf(id)) + } + + suspend fun removeModels(ids: List) { + val downloadModels = getDownloadModelsById(ids) + val removeIds = mutableListOf() + var hasDownloading = false + + downloadModels.forEach { downloadModel -> + removeIds.add(downloadModel.id) + + if (downloadModel.downloadedState == DownloadedState.DOWNLOADING) { + hasDownloading = true } + + try { + File(downloadModel.path).delete() + } catch (e: Exception) { + e.printStackTrace() + } + } + + if (hasDownloading) fileDownloader.cancelDownloading() + downloadDao.removeAllDownloadModels(removeIds) + + updateList() + + if (downloadTaskList.isEmpty()) { + workManager.cancelAllWorkByTag(DownloadWorker.WORKER_TAG) + } } - suspend fun cancelWork() { + suspend fun removeModels() { fileDownloader.cancelDownloading() workManager.cancelAllWorkByTag(DownloadWorker.WORKER_TAG) } @@ -101,4 +114,7 @@ class DownloadWorkerController( } } -} \ No newline at end of file + private suspend fun getDownloadModelsById(ids: List): List { + return downloadDao.readAllDataByIds(ids).first().map { it.mapToDomain() } + } +} diff --git a/core/src/main/java/org/openedx/core/module/db/DownloadDao.kt b/core/src/main/java/org/openedx/core/module/db/DownloadDao.kt index d3e9d84b7..5bdfc637b 100644 --- a/core/src/main/java/org/openedx/core/module/db/DownloadDao.kt +++ b/core/src/main/java/org/openedx/core/module/db/DownloadDao.kt @@ -10,7 +10,7 @@ interface DownloadDao { suspend fun removeDownloadModel(id: String) @Insert(onConflict = OnConflictStrategy.REPLACE) - suspend fun insertDownloadModel(vararg downloadModelEntity: DownloadModelEntity) + suspend fun insertDownloadModel(downloadModelEntities: List) @Update(onConflict = OnConflictStrategy.REPLACE) suspend fun updateDownloadModel(downloadModelEntity: DownloadModelEntity) @@ -18,4 +18,9 @@ interface DownloadDao { @Query("SELECT * FROM download_model") fun readAllData() : Flow> -} \ No newline at end of file + @Query("SELECT * FROM download_model WHERE id in (:ids)") + fun readAllDataByIds(ids: List) : Flow> + + @Query("DELETE FROM download_model WHERE id in (:ids)") + suspend fun removeAllDownloadModels(ids: List) +} diff --git a/core/src/main/java/org/openedx/core/module/download/BaseDownloadViewModel.kt b/core/src/main/java/org/openedx/core/module/download/BaseDownloadViewModel.kt index f5c7c8f8a..c96c986a2 100644 --- a/core/src/main/java/org/openedx/core/module/download/BaseDownloadViewModel.kt +++ b/core/src/main/java/org/openedx/core/module/download/BaseDownloadViewModel.kt @@ -2,20 +2,20 @@ package org.openedx.core.module.download import androidx.lifecycle.LifecycleOwner import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.launch import org.openedx.core.BaseViewModel import org.openedx.core.BlockType +import org.openedx.core.data.storage.CorePreferences import org.openedx.core.domain.model.Block import org.openedx.core.module.DownloadWorkerController import org.openedx.core.module.db.DownloadDao import org.openedx.core.module.db.DownloadModel import org.openedx.core.module.db.DownloadedState import org.openedx.core.utils.Sha1Util -import kotlinx.coroutines.flow.MutableSharedFlow -import kotlinx.coroutines.flow.asSharedFlow -import kotlinx.coroutines.flow.first -import kotlinx.coroutines.flow.map -import kotlinx.coroutines.launch -import org.openedx.core.data.storage.CorePreferences import java.io.File abstract class BaseDownloadViewModel( @@ -24,14 +24,18 @@ abstract class BaseDownloadViewModel( private val workerController: DownloadWorkerController ) : BaseViewModel() { - private val allBlocks = mutableListOf() + private val allBlocks = hashMapOf() - private var downloadableChildrenMap = hashMapOf>() + private val downloadableChildrenMap = hashMapOf>() private val downloadModelsStatus = hashMapOf() private val _downloadModelsStatusFlow = MutableSharedFlow>() protected val downloadModelsStatusFlow = _downloadModelsStatusFlow.asSharedFlow() + private var downloadingModelsList = listOf() + private val _downloadingModelsFlow = MutableSharedFlow>() + protected val downloadingModelsFlow = _downloadingModelsFlow.asSharedFlow() + override fun onCreate(owner: LifecycleOwner) { super.onCreate(owner) viewModelScope.launch { @@ -52,26 +56,46 @@ abstract class BaseDownloadViewModel( return downloadDao.readAllData().first().map { it.mapToDomain() } } - private fun updateDownloadModelsStatus(models: List) { + private suspend fun updateDownloadModelsStatus(models: List) { + val downloadModelMap = models.associateBy { it.id } for (item in downloadableChildrenMap) { - if (models.find { item.value.contains(it.id) && it.downloadedState.isWaitingOrDownloading } != null) { - downloadModelsStatus[item.key] = DownloadedState.DOWNLOADING - } else if (item.value.all { id -> models.find { it.id == id && it.downloadedState == DownloadedState.DOWNLOADED } != null }) { - downloadModelsStatus[item.key] = DownloadedState.DOWNLOADED - } else { - downloadModelsStatus[item.key] = DownloadedState.NOT_DOWNLOADED + var downloadingCount = 0 + var downloadedCount = 0 + item.value.forEach { blockId -> + val downloadModel = downloadModelMap[blockId] + if (downloadModel != null) { + if (downloadModel.downloadedState.isWaitingOrDownloading) { + downloadModelsStatus[blockId] = DownloadedState.DOWNLOADING + downloadingCount++ + } else if (downloadModel.downloadedState.isDownloaded) { + downloadModelsStatus[blockId] = DownloadedState.DOWNLOADED + downloadedCount++ + } + } else { + downloadModelsStatus[blockId] = DownloadedState.NOT_DOWNLOADED + } + } + + downloadModelsStatus[item.key] = when { + downloadingCount > 0 -> DownloadedState.DOWNLOADING + downloadedCount == item.value.size -> DownloadedState.DOWNLOADED + else -> DownloadedState.NOT_DOWNLOADED } } + + downloadingModelsList = models.filter { it.downloadedState.isWaitingOrDownloading } + _downloadingModelsFlow.emit(downloadingModelsList) } protected fun setBlocks(list: List) { + downloadableChildrenMap.clear() allBlocks.clear() - allBlocks.addAll(list) + allBlocks.putAll(list.map { it.id to it }) } fun isBlockDownloading(id: String): Boolean { val blockDownloadingState = downloadModelsStatus[id] - return blockDownloadingState == DownloadedState.DOWNLOADING || blockDownloadingState == DownloadedState.WAITING + return blockDownloadingState?.isWaitingOrDownloading == true } fun isBlockDownloaded(id: String): Boolean { @@ -82,73 +106,117 @@ abstract class BaseDownloadViewModel( open fun saveDownloadModels(folder: String, id: String) { viewModelScope.launch { val saveBlocksIds = downloadableChildrenMap[id] ?: listOf() - val downloadModels = mutableListOf() - for (blockId in saveBlocksIds) { - allBlocks.find { it.id == blockId }?.let { block -> - val videoInfo = - block.studentViewData?.encodedVideos?.getPreferredVideoInfoForDownloading( - preferencesManager.videoSettings.videoQuality - ) - val size = videoInfo?.fileSize ?: 0 - val url = videoInfo?.url ?: "" - val extension = url.split('.').lastOrNull() ?: "mp4" - val path = - folder + File.separator + "${Sha1Util.SHA1(block.displayName)}.$extension" - if (getDownloadModelList().find { it.id == blockId && it.downloadedState.isDownloaded } == null) { - downloadModels.add( - DownloadModel( - block.id, - block.displayName, - size, - path, - url, - block.downloadableType, - DownloadedState.WAITING, - null - ) + saveDownloadModels(folder, saveBlocksIds) + } + } + + open fun saveAllDownloadModels(folder: String) { + viewModelScope.launch { + val saveBlocksIds = downloadableChildrenMap.values.flatten() + saveDownloadModels(folder, saveBlocksIds) + } + } + + private suspend fun saveDownloadModels(folder: String, saveBlocksIds: List) { + val downloadModels = mutableListOf() + val downloadModelList = getDownloadModelList() + for (blockId in saveBlocksIds) { + allBlocks[blockId]?.let { block -> + val videoInfo = + block.studentViewData?.encodedVideos?.getPreferredVideoInfoForDownloading( + preferencesManager.videoSettings.videoDownloadQuality + ) + val size = videoInfo?.fileSize ?: 0 + val url = videoInfo?.url ?: "" + val extension = url.split('.').lastOrNull() ?: "mp4" + val path = + folder + File.separator + "${Sha1Util.SHA1(block.displayName)}.$extension" + if (downloadModelList.find { it.id == blockId && it.downloadedState.isDownloaded } == null) { + downloadModels.add( + DownloadModel( + block.id, + block.displayName, + size, + path, + url, + block.downloadableType, + DownloadedState.WAITING, + null ) - } + ) } } - workerController.saveModels(*downloadModels.toTypedArray()) } + workerController.saveModels(downloadModels) } - fun removeDownloadedModels(id: String) { - viewModelScope.launch { - val saveBlocksIds = downloadableChildrenMap[id] ?: listOf() - val downloaded = - getDownloadModelList().filter { saveBlocksIds.contains(it.id) && it.downloadedState.isDownloaded } - downloaded.forEach { - downloadDao.removeDownloadModel(it.id) + fun getDownloadModelsStatus() = downloadModelsStatus.toMap() + + fun getDownloadModelsSize(): DownloadModelsSize { + var isAllBlocksDownloadedOrDownloading = true + var remainingCount = 0 + var remainingSize = 0L + var allCount = 0 + var allSize = 0L + + downloadableChildrenMap.keys.forEach { id -> + if (!isBlockDownloaded(id) && !isBlockDownloading(id)) { + isAllBlocksDownloadedOrDownloading = false + } + + downloadableChildrenMap[id]?.forEach { downloadableBlock -> + val block = allBlocks[downloadableBlock] + val videoInfo = + block?.studentViewData?.encodedVideos?.getPreferredVideoInfoForDownloading( + preferencesManager.videoSettings.videoDownloadQuality + ) + + allCount++ + allSize += videoInfo?.fileSize ?: 0 + + if (!isBlockDownloaded(downloadableBlock)) { + remainingCount++ + remainingSize += videoInfo?.fileSize ?: 0 + } } } + return DownloadModelsSize( + isAllBlocksDownloadedOrDownloading = isAllBlocksDownloadedOrDownloading, + remainingCount = remainingCount, + remainingSize = remainingSize, + allCount = allCount, + allSize = allSize + ) } - fun getDownloadModelsStatus() = downloadModelsStatus.toMap() + fun hasDownloadModelsInQueue() = downloadingModelsList.isNotEmpty() - fun cancelWork(blockId: String) { + fun getDownloadableChildren(id: String) = downloadableChildrenMap[id] + + open fun removeDownloadModels(blockId: String) { viewModelScope.launch { val downloadableChildren = downloadableChildrenMap[blockId] ?: listOf() - val ids = getDownloadModelList().filter { - (it.downloadedState == DownloadedState.DOWNLOADING || - it.downloadedState == DownloadedState.WAITING) && downloadableChildren.contains( - it.id - ) - }.map { it.id } - workerController.cancelWork(*ids.toTypedArray()) + workerController.removeModels(downloadableChildren) + } + } + + fun removeAllDownloadModels() { + viewModelScope.launch { + val downloadableChildren = downloadableChildrenMap.values.flatten() + workerController.removeModels(downloadableChildren) } } protected fun addDownloadableChildrenForSequentialBlock(sequentialBlock: Block) { for (item in sequentialBlock.descendants) { - allBlocks.find { it.id == item }?.let { blockDescendant -> + allBlocks[item]?.let { blockDescendant -> if (blockDescendant.type == BlockType.VERTICAL) { for (unitBlockId in blockDescendant.descendants) { - allBlocks.find { it.id == unitBlockId && it.isDownloadable }?.let { + val block = allBlocks[unitBlockId] + if (block?.isDownloadable == true) { val id = sequentialBlock.id val children = downloadableChildrenMap[id] ?: listOf() - downloadableChildrenMap[id] = children + it.id + downloadableChildrenMap[id] = children + block.id } } } @@ -158,12 +226,13 @@ abstract class BaseDownloadViewModel( protected fun addDownloadableChildrenForVerticalBlock(verticalBlock: Block) { for (unitBlockId in verticalBlock.descendants) { - allBlocks.find { it.id == unitBlockId && it.isDownloadable }?.let { + val block = allBlocks[unitBlockId] + if (block?.isDownloadable == true) { val id = verticalBlock.id val children = downloadableChildrenMap[id] ?: listOf() - downloadableChildrenMap[id] = children + it.id + downloadableChildrenMap[id] = children + block.id } } } -} \ No newline at end of file +} diff --git a/core/src/main/java/org/openedx/core/module/download/DownloadModelsSize.kt b/core/src/main/java/org/openedx/core/module/download/DownloadModelsSize.kt new file mode 100644 index 000000000..b40876c99 --- /dev/null +++ b/core/src/main/java/org/openedx/core/module/download/DownloadModelsSize.kt @@ -0,0 +1,10 @@ +package org.openedx.core.module.download + +data class DownloadModelsSize( + val isAllBlocksDownloadedOrDownloading: Boolean, + val remainingCount: Int, + val remainingSize: Long, + val allCount: Int, + val allSize: Long +) + diff --git a/core/src/main/java/org/openedx/core/module/download/FileDownloader.kt b/core/src/main/java/org/openedx/core/module/download/FileDownloader.kt index 018c4c14f..fe68e696f 100644 --- a/core/src/main/java/org/openedx/core/module/download/FileDownloader.kt +++ b/core/src/main/java/org/openedx/core/module/download/FileDownloader.kt @@ -37,7 +37,7 @@ class FileDownloader : AbstractDownloader(), ProgressListener { } Log.d("DownloadProgress", "$bytesRead") if (contentLength != -1L) { - progressListener?.progress(100 * bytesRead / contentLength) + progressListener?.progress(bytesRead, contentLength) Log.d("DownloadProgress", "${100 * bytesRead / contentLength} done") } } @@ -46,7 +46,7 @@ class FileDownloader : AbstractDownloader(), ProgressListener { } interface CurrentProgress { - fun progress(value: Long) + fun progress(value: Long, size: Long) } interface DownloadApi { @@ -55,4 +55,4 @@ interface DownloadApi { @GET suspend fun downloadFile(@Url fileUrl: String): retrofit2.Response -} \ No newline at end of file +} diff --git a/profile/src/main/java/org/openedx/profile/presentation/settings/video/VideoQualityFragment.kt b/core/src/main/java/org/openedx/core/presentation/settings/VideoQualityFragment.kt similarity index 85% rename from profile/src/main/java/org/openedx/profile/presentation/settings/video/VideoQualityFragment.kt rename to core/src/main/java/org/openedx/core/presentation/settings/VideoQualityFragment.kt index cc76fc859..e26d882eb 100644 --- a/profile/src/main/java/org/openedx/profile/presentation/settings/video/VideoQualityFragment.kt +++ b/core/src/main/java/org/openedx/core/presentation/settings/VideoQualityFragment.kt @@ -1,7 +1,6 @@ -package org.openedx.profile.presentation.settings.video +package org.openedx.core.presentation.settings -import android.content.res.Configuration.UI_MODE_NIGHT_NO -import android.content.res.Configuration.UI_MODE_NIGHT_YES +import android.content.res.Configuration import android.os.Bundle import android.view.LayoutInflater import android.view.ViewGroup @@ -44,8 +43,11 @@ import androidx.compose.ui.semantics.testTagsAsResourceId 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.domain.model.VideoQuality import org.openedx.core.extension.nonZero import org.openedx.core.extension.tagId @@ -59,11 +61,14 @@ 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.R as profileR class VideoQualityFragment : Fragment() { - private val viewModel by viewModel() + private val viewModel by viewModel { + parametersOf( + requireArguments().getString(ARG_QUALITY_TYPE, "") + ) + } override fun onCreateView( inflater: LayoutInflater, @@ -75,13 +80,20 @@ class VideoQualityFragment : Fragment() { OpenEdXTheme { val windowSize = rememberWindowSize() - val videoQuality by viewModel.videoQuality.observeAsState(viewModel.currentVideoQuality) + val title = stringResource( + id = if (viewModel.getQualityType() == VideoQualityType.Streaming) + R.string.core_video_streaming_quality + else + R.string.core_video_download_quality + ) + val videoQuality by viewModel.videoQuality.observeAsState(viewModel.getCurrentVideoQuality()) VideoQualityScreen( windowSize = windowSize, + title = title, selectedVideoQuality = videoQuality, onQualityChanged = { - viewModel.setVideoDownloadQuality(it) + viewModel.setVideoQuality(it) }, onBackClick = { requireActivity().supportFragmentManager.popBackStack() @@ -90,12 +102,27 @@ class VideoQualityFragment : Fragment() { } } + companion object { + + private const val ARG_QUALITY_TYPE = "quality_type" + + fun newInstance( + type: String, + ): VideoQualityFragment { + val fragment = VideoQualityFragment() + fragment.arguments = bundleOf( + ARG_QUALITY_TYPE to type + ) + return fragment + } + } } @OptIn(ExperimentalComposeUiApi::class) @Composable private fun VideoQualityScreen( windowSize: WindowSize, + title: String, selectedVideoQuality: VideoQuality, onQualityChanged: (VideoQuality) -> Unit, onBackClick: () -> Unit @@ -145,7 +172,7 @@ private fun VideoQualityScreen( ) { Toolbar( modifier = topBarWidth, - label = stringResource(id = profileR.string.profile_video_streaming_quality), + label = title, canShowBackBtn = true, onBackClick = onBackClick ) @@ -223,15 +250,17 @@ private fun QualityOption( Divider() } -@Preview(uiMode = UI_MODE_NIGHT_NO) -@Preview(uiMode = UI_MODE_NIGHT_YES) +@Preview(uiMode = Configuration.UI_MODE_NIGHT_NO) +@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES) @Composable private fun VideoQualityScreenPreview() { OpenEdXTheme { VideoQualityScreen( windowSize = WindowSize(WindowType.Compact, WindowType.Compact), + title = "", selectedVideoQuality = VideoQuality.OPTION_720P, onQualityChanged = {}, onBackClick = {}) } } + diff --git a/core/src/main/java/org/openedx/core/presentation/settings/VideoQualityType.kt b/core/src/main/java/org/openedx/core/presentation/settings/VideoQualityType.kt new file mode 100644 index 000000000..4c7973d6a --- /dev/null +++ b/core/src/main/java/org/openedx/core/presentation/settings/VideoQualityType.kt @@ -0,0 +1,5 @@ +package org.openedx.core.presentation.settings + +enum class VideoQualityType { + Streaming, Download +} diff --git a/core/src/main/java/org/openedx/core/presentation/settings/VideoQualityViewModel.kt b/core/src/main/java/org/openedx/core/presentation/settings/VideoQualityViewModel.kt new file mode 100644 index 000000000..02f00851c --- /dev/null +++ b/core/src/main/java/org/openedx/core/presentation/settings/VideoQualityViewModel.kt @@ -0,0 +1,47 @@ +package org.openedx.core.presentation.settings + +import androidx.lifecycle.LiveData +import androidx.lifecycle.MutableLiveData +import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.launch +import org.openedx.core.BaseViewModel +import org.openedx.core.data.storage.CorePreferences +import org.openedx.core.domain.model.VideoQuality +import org.openedx.core.system.notifier.VideoNotifier +import org.openedx.core.system.notifier.VideoQualityChanged + +class VideoQualityViewModel( + private val preferencesManager: CorePreferences, + private val notifier: VideoNotifier, + private val qualityType: String +) : BaseViewModel() { + + private val _videoQuality = MutableLiveData() + val videoQuality: LiveData + get() = _videoQuality + + init { + _videoQuality.value = getCurrentVideoQuality() + } + + fun getCurrentVideoQuality(): VideoQuality { + return if (getQualityType() == VideoQualityType.Streaming) + preferencesManager.videoSettings.videoStreamingQuality else + preferencesManager.videoSettings.videoDownloadQuality + } + + fun setVideoQuality(quality: VideoQuality) { + val currentSettings = preferencesManager.videoSettings + if (getQualityType() == VideoQualityType.Streaming) { + preferencesManager.videoSettings = currentSettings.copy(videoStreamingQuality = quality) + } else { + preferencesManager.videoSettings = currentSettings.copy(videoDownloadQuality = quality) + } + _videoQuality.value = getCurrentVideoQuality() + viewModelScope.launch { + notifier.send(VideoQualityChanged()) + } + } + + fun getQualityType() = VideoQualityType.valueOf(qualityType) +} diff --git a/core/src/main/java/org/openedx/core/system/notifier/DownloadEvent.kt b/core/src/main/java/org/openedx/core/system/notifier/DownloadEvent.kt new file mode 100644 index 000000000..232616f79 --- /dev/null +++ b/core/src/main/java/org/openedx/core/system/notifier/DownloadEvent.kt @@ -0,0 +1,3 @@ +package org.openedx.core.system.notifier + +interface DownloadEvent diff --git a/core/src/main/java/org/openedx/core/system/notifier/DownloadNotifier.kt b/core/src/main/java/org/openedx/core/system/notifier/DownloadNotifier.kt new file mode 100644 index 000000000..eb16cf99f --- /dev/null +++ b/core/src/main/java/org/openedx/core/system/notifier/DownloadNotifier.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 DownloadNotifier { + + private val channel = MutableSharedFlow(replay = 0, extraBufferCapacity = 0) + + val notifier: Flow = channel.asSharedFlow() + + suspend fun send(event: DownloadProgressChanged) = channel.emit(event) + +} diff --git a/core/src/main/java/org/openedx/core/system/notifier/DownloadProgressChanged.kt b/core/src/main/java/org/openedx/core/system/notifier/DownloadProgressChanged.kt new file mode 100644 index 000000000..474b25f2f --- /dev/null +++ b/core/src/main/java/org/openedx/core/system/notifier/DownloadProgressChanged.kt @@ -0,0 +1,5 @@ +package org.openedx.core.system.notifier + +data class DownloadProgressChanged( + val id: String, val value: Long, val size: Long +) : DownloadEvent diff --git a/core/src/main/java/org/openedx/core/system/notifier/VideoEvent.kt b/core/src/main/java/org/openedx/core/system/notifier/VideoEvent.kt new file mode 100644 index 000000000..117019693 --- /dev/null +++ b/core/src/main/java/org/openedx/core/system/notifier/VideoEvent.kt @@ -0,0 +1,3 @@ +package org.openedx.core.system.notifier + +interface VideoEvent diff --git a/core/src/main/java/org/openedx/core/system/notifier/VideoNotifier.kt b/core/src/main/java/org/openedx/core/system/notifier/VideoNotifier.kt new file mode 100644 index 000000000..47287c603 --- /dev/null +++ b/core/src/main/java/org/openedx/core/system/notifier/VideoNotifier.kt @@ -0,0 +1,14 @@ +package org.openedx.core.system.notifier + +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.asSharedFlow + +class VideoNotifier { + + private val channel = MutableSharedFlow(replay = 0, extraBufferCapacity = 0) + + val notifier: Flow = channel.asSharedFlow() + + suspend fun send(event: VideoQualityChanged) = channel.emit(event) +} diff --git a/core/src/main/java/org/openedx/core/system/notifier/VideoQualityChanged.kt b/core/src/main/java/org/openedx/core/system/notifier/VideoQualityChanged.kt new file mode 100644 index 000000000..85eeb38c9 --- /dev/null +++ b/core/src/main/java/org/openedx/core/system/notifier/VideoQualityChanged.kt @@ -0,0 +1,3 @@ +package org.openedx.core.system.notifier + +class VideoQualityChanged : VideoEvent diff --git a/core/src/main/res/values-uk/strings.xml b/core/src/main/res/values-uk/strings.xml index ff7fa8fd6..5287ad2ea 100644 --- a/core/src/main/res/values-uk/strings.xml +++ b/core/src/main/res/values-uk/strings.xml @@ -65,4 +65,6 @@ %1$s зображення профілю + + Якість транслювання відео diff --git a/core/src/main/res/values/strings.xml b/core/src/main/res/values/strings.xml index c493da626..10ed72367 100644 --- a/core/src/main/res/values/strings.xml +++ b/core/src/main/res/values/strings.xml @@ -26,6 +26,9 @@ Password Soon Offline + Warning + Delete + Confirm Dismiss Reload Downloading in progress @@ -34,7 +37,7 @@ 360p Lower data usage 540p - 720p translatable="false" + 720p Best quality User account is not activated. Please activate your account first. Send email using… @@ -112,4 +115,12 @@ %1$s profile image + + Download to device + Downloading videos… + All videos downloaded + Remaining %d, %s Total + Videos %d, %s Total + Video streaming quality + Video download quality diff --git a/course/src/main/java/org/openedx/course/domain/interactor/CourseInteractor.kt b/course/src/main/java/org/openedx/course/domain/interactor/CourseInteractor.kt index 8b0fb0f03..52a8a55ec 100644 --- a/course/src/main/java/org/openedx/course/domain/interactor/CourseInteractor.kt +++ b/course/src/main/java/org/openedx/course/domain/interactor/CourseInteractor.kt @@ -43,15 +43,13 @@ class CourseInteractor( blocks.firstOrNull { it.descendants.contains(sequentialBlock.id) } if (chapterBlock != null) { resultBlocks.add(videoBlock) - if (!resultBlocks.contains(verticalBlock)) { + val verticalIndex = resultBlocks.indexOfFirst { it.id == verticalBlock.id } + if (verticalIndex == -1) { resultBlocks.add(verticalBlock.copy(descendants = listOf(videoBlock.id))) } else { - val index = resultBlocks.indexOfFirst { it.id == verticalBlock.id } - if (index != -1) { - val block = resultBlocks[index] - resultBlocks[index] = - block.copy(descendants = block.descendants + videoBlock.id) - } + val block = resultBlocks[verticalIndex] + resultBlocks[verticalIndex] = + block.copy(descendants = block.descendants + videoBlock.id) } if (!resultBlocks.contains(sequentialBlock)) { resultBlocks.add(sequentialBlock) diff --git a/course/src/main/java/org/openedx/course/presentation/CourseRouter.kt b/course/src/main/java/org/openedx/course/presentation/CourseRouter.kt index 2eafb8d97..fae658fde 100644 --- a/course/src/main/java/org/openedx/course/presentation/CourseRouter.kt +++ b/course/src/main/java/org/openedx/course/presentation/CourseRouter.kt @@ -2,6 +2,7 @@ package org.openedx.course.presentation import androidx.fragment.app.FragmentManager import org.openedx.core.presentation.course.CourseViewMode +import org.openedx.core.presentation.settings.VideoQualityType import org.openedx.course.presentation.handouts.HandoutsType interface CourseRouter { @@ -73,4 +74,8 @@ interface CourseRouter { fun navigateToSignIn(fm: FragmentManager, courseId: String?, infoType: String?) fun navigateToLogistration(fm: FragmentManager, courseId: String?) + + fun navigateToDownloadQueue(fm: FragmentManager, descendants: List = arrayListOf()) + + fun navigateToVideoQuality(fm: FragmentManager, videoQualityType: VideoQualityType) } diff --git a/course/src/main/java/org/openedx/course/presentation/container/CourseContainerAdapter.kt b/course/src/main/java/org/openedx/course/presentation/container/CourseContainerAdapter.kt index cb9ca5930..d9447487c 100644 --- a/course/src/main/java/org/openedx/course/presentation/container/CourseContainerAdapter.kt +++ b/course/src/main/java/org/openedx/course/presentation/container/CourseContainerAdapter.kt @@ -24,8 +24,8 @@ class CourseContainerAdapter(fragment: Fragment) : FragmentStateAdapter(fragment enum class CourseContainerTab(val itemId: Int, val titleResId: Int) { COURSE(itemId = R.id.course, titleResId = R.string.course_navigation_course), - VIDEOS(itemId = R.id.videos, titleResId = R.string.course_navigation_video), - DISCUSSION(itemId = R.id.discussions, titleResId = R.string.course_navigation_discussion), + VIDEOS(itemId = R.id.videos, titleResId = R.string.course_navigation_videos), + DISCUSSION(itemId = R.id.discussions, titleResId = R.string.course_navigation_discussions), DATES(itemId = R.id.dates, titleResId = R.string.course_navigation_dates), HANDOUTS(itemId = R.id.resources, titleResId = R.string.course_navigation_handouts), } diff --git a/course/src/main/java/org/openedx/course/presentation/container/CourseContainerFragment.kt b/course/src/main/java/org/openedx/course/presentation/container/CourseContainerFragment.kt index f2a27d510..936e75294 100644 --- a/course/src/main/java/org/openedx/course/presentation/container/CourseContainerFragment.kt +++ b/course/src/main/java/org/openedx/course/presentation/container/CourseContainerFragment.kt @@ -173,3 +173,4 @@ class CourseContainerFragment : Fragment(R.layout.fragment_course_container) { } } } + diff --git a/course/src/main/java/org/openedx/course/presentation/outline/CourseOutlineFragment.kt b/course/src/main/java/org/openedx/course/presentation/outline/CourseOutlineFragment.kt index e760400e9..f00055fa9 100644 --- a/course/src/main/java/org/openedx/course/presentation/outline/CourseOutlineFragment.kt +++ b/course/src/main/java/org/openedx/course/presentation/outline/CourseOutlineFragment.kt @@ -181,9 +181,12 @@ class CourseOutlineFragment : Fragment() { }, onDownloadClick = { if (viewModel.isBlockDownloading(it.id)) { - viewModel.cancelWork(it.id) + router.navigateToDownloadQueue( + fm = requireActivity().supportFragmentManager, + viewModel.getDownloadableChildren(it.id) ?: arrayListOf() + ) } else if (viewModel.isBlockDownloaded(it.id)) { - viewModel.removeDownloadedModels(it.id) + viewModel.removeDownloadModels(it.id) } else { viewModel.saveDownloadModels( requireContext().externalCacheDir.toString() + diff --git a/course/src/main/java/org/openedx/course/presentation/section/CourseSectionFragment.kt b/course/src/main/java/org/openedx/course/presentation/section/CourseSectionFragment.kt index fe6205879..882bafc9b 100644 --- a/course/src/main/java/org/openedx/course/presentation/section/CourseSectionFragment.kt +++ b/course/src/main/java/org/openedx/course/presentation/section/CourseSectionFragment.kt @@ -96,10 +96,8 @@ class CourseSectionFragment : Fragment() { } }, onDownloadClick = { - if (viewModel.isBlockDownloading(it.id)) { - viewModel.cancelWork(it.id) - } else if (viewModel.isBlockDownloaded(it.id)) { - viewModel.removeDownloadedModels(it.id) + if (viewModel.isBlockDownloading(it.id) || viewModel.isBlockDownloaded(it.id)) { + viewModel.removeDownloadModels(it.id) } else { viewModel.saveDownloadModels( requireContext().externalCacheDir.toString() + diff --git a/course/src/main/java/org/openedx/course/presentation/ui/CourseUI.kt b/course/src/main/java/org/openedx/course/presentation/ui/CourseUI.kt index 3595c9652..63e11c7f5 100644 --- a/course/src/main/java/org/openedx/course/presentation/ui/CourseUI.kt +++ b/course/src/main/java/org/openedx/course/presentation/ui/CourseUI.kt @@ -35,6 +35,7 @@ import androidx.compose.material.CircularProgressIndicator import androidx.compose.material.Divider import androidx.compose.material.Icon import androidx.compose.material.IconButton +import androidx.compose.material.LinearProgressIndicator import androidx.compose.material.MaterialTheme import androidx.compose.material.OutlinedButton import androidx.compose.material.Scaffold @@ -86,7 +87,10 @@ import org.openedx.core.domain.model.EnrolledCourse import org.openedx.core.domain.model.EnrolledCourseData import org.openedx.core.extension.isLinkValid import org.openedx.core.extension.nonZero +import org.openedx.core.extension.toFileSize +import org.openedx.core.module.db.DownloadModel import org.openedx.core.module.db.DownloadedState +import org.openedx.core.module.db.FileType import org.openedx.core.ui.BackBtn import org.openedx.core.ui.IconText import org.openedx.core.ui.OpenEdXButton @@ -292,6 +296,77 @@ fun CourseSectionCard( } } +@Composable +fun OfflineQueueCard( + downloadModel: DownloadModel, + progressValue: Long, + progressSize: Long, + onDownloadClick: (DownloadModel) -> Unit +) { + val iconModifier = Modifier.size(24.dp) + + Row( + Modifier + .fillMaxWidth() + .padding(vertical = 16.dp) + .padding(start = 20.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween + ) { + Column( + modifier = Modifier + .weight(1f) + ) { + Text( + text = downloadModel.title.ifEmpty { stringResource(id = R.string.course_download_untitled) }, + style = MaterialTheme.appTypography.titleSmall, + color = MaterialTheme.appColors.textPrimary, + overflow = TextOverflow.Ellipsis, + maxLines = 1 + ) + Text( + text = downloadModel.size.toLong().toFileSize(), + style = MaterialTheme.appTypography.titleSmall, + color = MaterialTheme.appColors.textSecondary, + overflow = TextOverflow.Ellipsis, + maxLines = 1 + ) + + val progress = progressValue.toFloat() / progressSize + + LinearProgressIndicator( + modifier = Modifier + .fillMaxWidth() + .padding(top = 8.dp), + progress = progress + ) + } + Spacer(modifier = Modifier.width(16.dp)) + Box( + modifier = Modifier + .padding(end = 16.dp), + contentAlignment = Alignment.Center + ) { + CircularProgressIndicator( + modifier = Modifier.size(28.dp), + backgroundColor = Color.LightGray, + strokeWidth = 2.dp, + color = MaterialTheme.appColors.primary + ) + IconButton( + modifier = iconModifier + .padding(2.dp), + onClick = { onDownloadClick(downloadModel) }) { + Icon( + imageVector = Icons.Filled.Close, + contentDescription = stringResource(id = R.string.course_accessibility_stop_downloading_course_section), + tint = MaterialTheme.appColors.error + ) + } + } + } +} + @Composable fun CardArrow( degrees: Float @@ -758,10 +833,12 @@ fun CourseSubSectionItem( IconButton( modifier = iconModifier.padding(2.dp), onClick = { onDownloadClick(block) }) { - Icon( - imageVector = Icons.Filled.Close, - contentDescription = stringResource(id = R.string.course_accessibility_stop_downloading_course_section), - tint = MaterialTheme.appColors.error + Text( + modifier = Modifier + .padding(bottom = 4.dp), + text = "i", + style = MaterialTheme.appTypography.titleMedium, + color = MaterialTheme.appColors.primary ) } } @@ -1206,6 +1283,31 @@ private fun CourseDatesBannerTabletPreview() { } } +@Preview(uiMode = Configuration.UI_MODE_NIGHT_NO) +@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES) +@Composable +private fun OfflineQueueCardPreview() { + OpenEdXTheme { + Surface(color = MaterialTheme.appColors.background) { + OfflineQueueCard( + downloadModel = DownloadModel( + id = "", + title = "Problems of society", + size = 4000, + path = "", + url = "", + type = FileType.VIDEO, + downloadedState = DownloadedState.DOWNLOADING, + progress = 0f + ), + progressValue = 10, + progressSize = 30, + onDownloadClick = {} + ) + } + } +} + private val mockCourse = EnrolledCourse( auditAccessExpires = Date(), created = "created", diff --git a/course/src/main/java/org/openedx/course/presentation/ui/CourseVideosUI.kt b/course/src/main/java/org/openedx/course/presentation/ui/CourseVideosUI.kt new file mode 100644 index 000000000..8d5bc7172 --- /dev/null +++ b/course/src/main/java/org/openedx/course/presentation/ui/CourseVideosUI.kt @@ -0,0 +1,827 @@ +package org.openedx.course.presentation.ui + +import android.content.res.Configuration +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.core.LinearEasing +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.tween +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.aspectRatio +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.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material.AlertDialog +import androidx.compose.material.CircularProgressIndicator +import androidx.compose.material.Divider +import androidx.compose.material.ExperimentalMaterialApi +import androidx.compose.material.Icon +import androidx.compose.material.LinearProgressIndicator +import androidx.compose.material.MaterialTheme +import androidx.compose.material.Scaffold +import androidx.compose.material.Surface +import androidx.compose.material.Switch +import androidx.compose.material.SwitchDefaults +import androidx.compose.material.Text +import androidx.compose.material.TextButton +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.ChevronRight +import androidx.compose.material.icons.outlined.Settings +import androidx.compose.material.icons.outlined.Videocam +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.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 org.openedx.core.AppDataConstants +import org.openedx.core.BlockType +import org.openedx.core.UIMessage +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.domain.model.VideoSettings +import org.openedx.core.extension.toFileSize +import org.openedx.core.module.download.DownloadModelsSize +import org.openedx.core.ui.HandleUIMessage +import org.openedx.core.ui.OfflineModeDialog +import org.openedx.core.ui.WindowSize +import org.openedx.core.ui.WindowType +import org.openedx.core.ui.displayCutoutForLandscape +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.course.R +import org.openedx.course.presentation.videos.CourseVideosUIState +import java.util.Date + +@OptIn(ExperimentalMaterialApi::class) +@Composable +fun CourseVideosScreen( + windowSize: WindowSize, + uiState: CourseVideosUIState, + uiMessage: UIMessage?, + courseTitle: String, + apiHostUrl: String, + isCourseNestedListEnabled: Boolean, + isCourseBannerEnabled: Boolean, + isUpdating: Boolean, + hasInternetConnection: Boolean, + videoSettings: VideoSettings, + onSwipeRefresh: () -> Unit, + onItemClick: (Block) -> Unit, + onExpandClick: (Block) -> Unit, + onSubSectionClick: (Block) -> Unit, + onReloadClick: () -> Unit, + onDownloadClick: (Block) -> Unit, + onDownloadAllClick: (Boolean) -> Unit, + onDownloadQueueClick: () -> Unit, + onVideoDownloadQualityClick: () -> Unit +) { + val scaffoldState = rememberScaffoldState() + val pullRefreshState = + rememberPullRefreshState(refreshing = isUpdating, onRefresh = { onSwipeRefresh() }) + + var isInternetConnectionShown by rememberSaveable { + mutableStateOf(false) + } + + Scaffold( + modifier = Modifier + .fillMaxSize(), + 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() + ) + ) + } + + val listBottomPadding by remember(key1 = windowSize) { + mutableStateOf( + windowSize.windowSizeValue( + expanded = PaddingValues(bottom = 24.dp), + compact = PaddingValues(bottom = 24.dp) + ) + ) + } + + val listPadding by remember(key1 = windowSize) { + mutableStateOf( + windowSize.windowSizeValue( + expanded = Modifier.padding(horizontal = 6.dp), + compact = Modifier.padding(horizontal = 24.dp) + ) + ) + } + + var isDownloadConfirmationShowed by rememberSaveable { + mutableStateOf(false) + } + + var isDeleteDownloadsConfirmationShowed by rememberSaveable { + mutableStateOf(false) + } + + var deleteDownloadBlock by rememberSaveable { + mutableStateOf(null) + } + + HandleUIMessage(uiMessage = uiMessage, scaffoldState = scaffoldState) + + Box( + modifier = Modifier + .fillMaxSize() + .padding(it) + .displayCutoutForLandscape(), + contentAlignment = Alignment.TopCenter + ) { + Surface( + modifier = screenWidth, + color = MaterialTheme.appColors.background + ) { + Box(Modifier.pullRefresh(pullRefreshState)) { + Column( + Modifier + .fillMaxSize() + ) { + when (uiState) { + is CourseVideosUIState.Empty -> { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center + ) { + Text( + text = stringResource(id = R.string.course_does_not_include_videos), + color = MaterialTheme.appColors.textPrimary, + style = MaterialTheme.appTypography.headlineSmall, + textAlign = TextAlign.Center, + modifier = Modifier.padding(horizontal = 40.dp) + ) + } + } + + is CourseVideosUIState.Loading -> { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center + ) { + CircularProgressIndicator(color = MaterialTheme.appColors.primary) + } + } + + is CourseVideosUIState.CourseData -> { + LazyColumn( + modifier = Modifier.fillMaxSize(), + contentPadding = listBottomPadding + ) { + if (isCourseBannerEnabled) { + item { + CourseImageHeader( + modifier = Modifier + .aspectRatio(1.86f) + .padding(6.dp), + apiHostUrl = apiHostUrl, + courseImage = uiState.courseStructure.media?.image?.large + ?: "", + courseCertificate = uiState.courseStructure.certificate, + courseName = uiState.courseStructure.name + ) + } + } + + if (uiState.downloadModelsSize.allCount > 0) { + item { + AllVideosDownloadItem( + downloadModelsSize = uiState.downloadModelsSize, + videoSettings = videoSettings, + onShowDownloadConfirmationDialog = { + isDownloadConfirmationShowed = true + }, + onDownloadAllClick = { isSwitched -> + if (isSwitched) { + isDeleteDownloadsConfirmationShowed = true + + } else { + onDownloadAllClick(false) + } + }, + onDownloadQueueClick = onDownloadQueueClick, + onVideoDownloadQualityClick = onVideoDownloadQualityClick + ) + } + } + + if (isCourseNestedListEnabled) { + uiState.courseStructure.blockData.forEach { section -> + val courseSubSections = + uiState.courseSubSections[section.id] + val courseSectionsState = + uiState.courseSectionsState[section.id] + + item { + Column { + CourseExpandableChapterCard( + modifier = listPadding, + block = section, + onItemClick = onExpandClick, + arrowDegrees = if (courseSectionsState == true) -90f else 90f + ) + Divider() + } + } + + courseSubSections?.forEach { subSectionBlock -> + item { + Column { + AnimatedVisibility( + visible = courseSectionsState == true + ) { + Column { + val downloadsCount = + uiState.subSectionsDownloadsCount[subSectionBlock.id] + ?: 0 + + CourseSubSectionItem( + modifier = listPadding, + block = subSectionBlock, + downloadedState = uiState.downloadedState[subSectionBlock.id], + downloadsCount = downloadsCount, + onClick = onSubSectionClick, + onDownloadClick = { block -> + if (uiState.downloadedState[block.id]?.isDownloaded == true) { + deleteDownloadBlock = + block + + } else { + onDownloadClick(block) + } + } + ) + Divider() + } + } + } + } + } + } + return@LazyColumn + } + + items(uiState.courseStructure.blockData) { block -> + Column(listPadding) { + if (block.type == BlockType.CHAPTER) { + Text( + modifier = Modifier.padding( + top = 36.dp, + bottom = 8.dp + ), + text = block.displayName, + style = MaterialTheme.appTypography.titleMedium, + color = MaterialTheme.appColors.textPrimaryVariant + ) + } else { + CourseSectionCard( + block = block, + downloadedState = uiState.downloadedState[block.id], + onItemClick = onItemClick, + onDownloadClick = { block -> + if (uiState.downloadedState[block.id]?.isDownloaded == true) { + deleteDownloadBlock = block + + } else { + onDownloadClick(block) + } + } + ) + Divider() + } + } + } + } + } + } + } + PullRefreshIndicator( + isUpdating, + pullRefreshState, + Modifier.align(Alignment.TopCenter) + ) + if (!isInternetConnectionShown && !hasInternetConnection) { + OfflineModeDialog( + Modifier + .fillMaxWidth() + .align(Alignment.BottomCenter), + onDismissCLick = { + isInternetConnectionShown = true + }, + onReloadClick = { + isInternetConnectionShown = true + onReloadClick() + } + ) + } + } + } + } + + if (isDownloadConfirmationShowed) { + AlertDialog( + title = { + Text( + text = stringResource(id = R.string.course_download_big_files_confirmation_title) + ) + }, + text = { + Text( + text = stringResource(id = R.string.course_download_big_files_confirmation_text) + ) + }, + onDismissRequest = { + isDownloadConfirmationShowed = false + }, + confirmButton = { + TextButton( + onClick = { + isDownloadConfirmationShowed = false + onDownloadAllClick(false) + } + ) { + Text( + text = stringResource(id = org.openedx.core.R.string.core_confirm) + ) + } + }, + dismissButton = { + TextButton( + onClick = { + isDownloadConfirmationShowed = false + } + ) { + Text(text = stringResource(id = org.openedx.core.R.string.core_dismiss)) + } + } + ) + } + + if (isDeleteDownloadsConfirmationShowed) { + val downloadModelsSize = + (uiState as? CourseVideosUIState.CourseData)?.downloadModelsSize + val isDownloadedAllVideos = + downloadModelsSize?.isAllBlocksDownloadedOrDownloading == true && + downloadModelsSize.remainingCount == 0 + val dialogTextId = if (isDownloadedAllVideos) + R.string.course_delete_downloads_confirmation_text else + R.string.course_delete_while_downloading_confirmation_text + + AlertDialog( + title = { + Text( + text = stringResource(id = org.openedx.core.R.string.core_warning) + ) + }, + text = { + Text( + text = stringResource(id = dialogTextId, courseTitle) + ) + }, + onDismissRequest = { + isDeleteDownloadsConfirmationShowed = false + }, + confirmButton = { + TextButton( + onClick = { + isDeleteDownloadsConfirmationShowed = false + onDownloadAllClick(true) + } + ) { + Text( + text = stringResource(id = org.openedx.core.R.string.core_delete) + ) + } + }, + dismissButton = { + TextButton( + onClick = { + isDeleteDownloadsConfirmationShowed = false + } + ) { + Text(text = stringResource(id = org.openedx.core.R.string.core_cancel)) + } + } + ) + } + + if (deleteDownloadBlock != null) { + AlertDialog( + title = { + Text( + text = stringResource(id = org.openedx.core.R.string.core_warning) + ) + }, + text = { + Text( + text = stringResource( + id = R.string.course_delete_download_confirmation_text, + deleteDownloadBlock?.displayName ?: "" + ) + ) + }, + onDismissRequest = { + deleteDownloadBlock = null + }, + confirmButton = { + TextButton( + onClick = { + deleteDownloadBlock?.let { block -> + onDownloadClick(block) + } + deleteDownloadBlock = null + } + ) { + Text( + text = stringResource(id = org.openedx.core.R.string.core_delete) + ) + } + }, + dismissButton = { + TextButton( + onClick = { + deleteDownloadBlock = null + } + ) { + Text(text = stringResource(id = org.openedx.core.R.string.core_cancel)) + } + } + ) + } + } +} + +@Composable +private fun AllVideosDownloadItem( + downloadModelsSize: DownloadModelsSize, + videoSettings: VideoSettings, + onShowDownloadConfirmationDialog: () -> Unit, + onDownloadAllClick: (Boolean) -> Unit, + onDownloadQueueClick: () -> Unit, + onVideoDownloadQualityClick: () -> Unit +) { + val isDownloadingAllVideos = + downloadModelsSize.isAllBlocksDownloadedOrDownloading && + downloadModelsSize.remainingCount > 0 + val isDownloadedAllVideos = + downloadModelsSize.isAllBlocksDownloadedOrDownloading && + downloadModelsSize.remainingCount == 0 + + val downloadVideoTitleRes = when { + isDownloadingAllVideos -> org.openedx.core.R.string.core_video_downloading_to_device + isDownloadedAllVideos -> org.openedx.core.R.string.core_video_downloaded_to_device + else -> org.openedx.core.R.string.core_video_download_to_device + } + val downloadVideoSubTitle = + if (isDownloadedAllVideos) { + stringResource( + id = org.openedx.core.R.string.core_video_downloaded_subtitle, + downloadModelsSize.allCount, + downloadModelsSize.allSize.toFileSize() + ) + } else { + stringResource( + id = org.openedx.core.R.string.core_video_remaining_to_download, + downloadModelsSize.remainingCount, + downloadModelsSize.remainingSize.toFileSize() + ) + } + + Row( + modifier = Modifier + .fillMaxWidth() + .clickable { + onDownloadQueueClick() + }, + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + if (isDownloadingAllVideos) { + CircularProgressIndicator( + modifier = Modifier + .padding(start = 16.dp) + .size(24.dp), + color = MaterialTheme.appColors.primary, + strokeWidth = 2.dp + ) + } else { + Icon( + modifier = Modifier + .padding(start = 16.dp), + imageVector = Icons.Outlined.Videocam, + tint = MaterialTheme.appColors.onSurface, + contentDescription = null + ) + } + Column( + modifier = Modifier + .weight(1f) + .padding(8.dp) + ) { + Text( + text = stringResource(id = downloadVideoTitleRes), + color = MaterialTheme.appColors.textPrimary, + style = MaterialTheme.appTypography.titleMedium + ) + Spacer(modifier = Modifier.height(2.dp)) + Text( + text = downloadVideoSubTitle, + color = MaterialTheme.appColors.textSecondary, + style = MaterialTheme.appTypography.labelMedium + ) + } + val isChecked = downloadModelsSize.isAllBlocksDownloadedOrDownloading + Switch( + modifier = Modifier + .padding(end = 16.dp), + checked = isChecked, + onCheckedChange = { + if (!isChecked) { + if ( + downloadModelsSize.remainingSize > AppDataConstants.DOWNLOADS_CONFIRMATION_SIZE + ) { + onShowDownloadConfirmationDialog() + } else { + onDownloadAllClick(false) + } + + } else { + onDownloadAllClick(true) + } + }, + colors = SwitchDefaults.colors( + checkedThumbColor = MaterialTheme.appColors.primary, + checkedTrackColor = MaterialTheme.appColors.primary + ) + ) + } + if (isDownloadingAllVideos) { + val progress = 1 - downloadModelsSize.remainingSize.toFloat() / downloadModelsSize.allSize + + val animatedProgress by animateFloatAsState( + targetValue = progress, + animationSpec = tween(durationMillis = 2000, easing = LinearEasing), + label = "ProgressAnimation" + ) + LinearProgressIndicator( + modifier = Modifier + .fillMaxWidth(), + progress = animatedProgress + ) + } + Divider() + Row( + modifier = Modifier + .fillMaxWidth() + .clickable { + onVideoDownloadQualityClick() + }, + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + modifier = Modifier + .padding(start = 16.dp), + imageVector = Icons.Outlined.Settings, + tint = MaterialTheme.appColors.onSurface, + contentDescription = null + ) + Column( + modifier = Modifier + .weight(1f) + .padding(8.dp) + ) { + Text( + text = stringResource(id = org.openedx.core.R.string.core_video_download_quality), + color = MaterialTheme.appColors.textPrimary, + style = MaterialTheme.appTypography.titleMedium + ) + Spacer(modifier = Modifier.height(2.dp)) + Text( + text = stringResource(id = videoSettings.videoDownloadQuality.titleResId), + color = MaterialTheme.appColors.textSecondary, + style = MaterialTheme.appTypography.labelMedium + ) + } + Icon( + modifier = Modifier + .padding(end = 16.dp), + imageVector = Icons.Filled.ChevronRight, + tint = MaterialTheme.appColors.onSurface, + contentDescription = "Expandable Arrow" + ) + } + Divider() +} + +@Preview(uiMode = Configuration.UI_MODE_NIGHT_NO) +@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES) +@Composable +private fun CourseVideosScreenPreview() { + OpenEdXTheme { + CourseVideosScreen( + windowSize = WindowSize(WindowType.Compact, WindowType.Compact), + uiMessage = null, + uiState = CourseVideosUIState.CourseData( + mockCourseStructure, + emptyMap(), + mapOf(), + mapOf(), + mapOf(), + DownloadModelsSize( + isAllBlocksDownloadedOrDownloading = false, + remainingCount = 0, + remainingSize = 0, + allCount = 0, + allSize = 0 + ) + ), + courseTitle = "", + apiHostUrl = "", + isCourseNestedListEnabled = false, + isCourseBannerEnabled = true, + onItemClick = { }, + onExpandClick = { }, + onSubSectionClick = { }, + hasInternetConnection = true, + isUpdating = false, + videoSettings = VideoSettings.default, + onSwipeRefresh = {}, + onReloadClick = {}, + onDownloadClick = {}, + onDownloadAllClick = {}, + onDownloadQueueClick = {}, + onVideoDownloadQualityClick = {} + ) + } +} + +@Preview(uiMode = Configuration.UI_MODE_NIGHT_NO) +@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES) +@Composable +private fun CourseVideosScreenEmptyPreview() { + OpenEdXTheme { + CourseVideosScreen( + windowSize = WindowSize(WindowType.Compact, WindowType.Compact), + uiMessage = null, + uiState = CourseVideosUIState.Empty( + "This course does not include any videos." + ), + courseTitle = "", + apiHostUrl = "", + isCourseNestedListEnabled = false, + isCourseBannerEnabled = true, + onItemClick = { }, + onExpandClick = { }, + onSubSectionClick = { }, + onSwipeRefresh = {}, + onReloadClick = {}, + hasInternetConnection = true, + isUpdating = false, + videoSettings = VideoSettings.default, + onDownloadClick = {}, + onDownloadAllClick = {}, + onDownloadQueueClick = {}, + onVideoDownloadQualityClick = {} + ) + } +} + +@Preview(uiMode = Configuration.UI_MODE_NIGHT_NO, device = Devices.NEXUS_9) +@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES, device = Devices.NEXUS_9) +@Composable +private fun CourseVideosScreenTabletPreview() { + OpenEdXTheme { + CourseVideosScreen( + windowSize = WindowSize(WindowType.Medium, WindowType.Medium), + uiMessage = null, + uiState = CourseVideosUIState.CourseData( + mockCourseStructure, + emptyMap(), + mapOf(), + mapOf(), + mapOf(), + DownloadModelsSize( + isAllBlocksDownloadedOrDownloading = false, + remainingCount = 0, + remainingSize = 0, + allCount = 0, + allSize = 0 + ) + ), + courseTitle = "", + apiHostUrl = "", + isCourseNestedListEnabled = false, + isCourseBannerEnabled = true, + onItemClick = { }, + onExpandClick = { }, + onSubSectionClick = { }, + onSwipeRefresh = {}, + onReloadClick = {}, + isUpdating = false, + hasInternetConnection = true, + videoSettings = VideoSettings.default, + onDownloadClick = {}, + onDownloadAllClick = {}, + onDownloadQueueClick = {}, + onVideoDownloadQualityClick = {} + ) + } +} + + +private val mockChapterBlock = Block( + id = "id", + blockId = "blockId", + lmsWebUrl = "lmsWebUrl", + legacyWebUrl = "legacyWebUrl", + studentViewUrl = "studentViewUrl", + type = BlockType.CHAPTER, + displayName = "Chapter", + graded = false, + studentViewData = null, + studentViewMultiDevice = false, + blockCounts = BlockCounts(1), + descendants = emptyList(), + descendantsType = BlockType.CHAPTER, + completion = 0.0, + containsGatedContent = false +) + +private val mockSequentialBlock = Block( + id = "id", + blockId = "blockId", + lmsWebUrl = "lmsWebUrl", + legacyWebUrl = "legacyWebUrl", + studentViewUrl = "studentViewUrl", + type = BlockType.SEQUENTIAL, + displayName = "Sequential", + graded = false, + studentViewData = null, + studentViewMultiDevice = false, + blockCounts = BlockCounts(1), + descendants = emptyList(), + descendantsType = BlockType.SEQUENTIAL, + completion = 0.0, + containsGatedContent = false +) + +private val mockCourseStructure = CourseStructure( + root = "", + blockData = listOf(mockSequentialBlock, mockChapterBlock), + id = "id", + name = "Course name", + number = "", + org = "Org", + start = Date(), + startDisplay = "", + startType = "", + end = Date(), + coursewareAccess = CoursewareAccess( + true, + "", + "", + "", + "", + "" + ), + media = null, + certificate = null, + isSelfPaced = false +) diff --git a/course/src/main/java/org/openedx/course/presentation/unit/video/EncodedVideoUnitViewModel.kt b/course/src/main/java/org/openedx/course/presentation/unit/video/EncodedVideoUnitViewModel.kt index 73b15366f..1ff0df22e 100644 --- a/course/src/main/java/org/openedx/course/presentation/unit/video/EncodedVideoUnitViewModel.kt +++ b/course/src/main/java/org/openedx/course/presentation/unit/video/EncodedVideoUnitViewModel.kt @@ -141,5 +141,5 @@ class EncodedVideoUnitViewModel( ).build() } - private fun getVideoQuality() = preferencesManager.videoSettings.videoQuality -} \ No newline at end of file + private fun getVideoQuality() = preferencesManager.videoSettings.videoStreamingQuality +} diff --git a/course/src/main/java/org/openedx/course/presentation/unit/video/VideoViewModel.kt b/course/src/main/java/org/openedx/course/presentation/unit/video/VideoViewModel.kt index 6e127aeb4..c5d1430a7 100644 --- a/course/src/main/java/org/openedx/course/presentation/unit/video/VideoViewModel.kt +++ b/course/src/main/java/org/openedx/course/presentation/unit/video/VideoViewModel.kt @@ -49,5 +49,5 @@ class VideoViewModel( } } - fun getVideoQuality() = preferencesManager.videoSettings.videoQuality -} \ No newline at end of file + fun getVideoQuality() = preferencesManager.videoSettings.videoStreamingQuality +} diff --git a/course/src/main/java/org/openedx/course/presentation/videos/CourseVideoViewModel.kt b/course/src/main/java/org/openedx/course/presentation/videos/CourseVideoViewModel.kt index 196bd28d2..03190efce 100644 --- a/course/src/main/java/org/openedx/course/presentation/videos/CourseVideoViewModel.kt +++ b/course/src/main/java/org/openedx/course/presentation/videos/CourseVideoViewModel.kt @@ -1,9 +1,10 @@ package org.openedx.course.presentation.videos -import androidx.lifecycle.LifecycleOwner import androidx.lifecycle.LiveData import androidx.lifecycle.MutableLiveData import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.launch import org.openedx.core.BlockType import org.openedx.core.SingleEventLiveData @@ -11,6 +12,7 @@ import org.openedx.core.UIMessage 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.VideoSettings import org.openedx.core.module.DownloadWorkerController import org.openedx.core.module.db.DownloadDao import org.openedx.core.module.download.BaseDownloadViewModel @@ -18,6 +20,8 @@ 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.core.system.notifier.VideoNotifier +import org.openedx.core.system.notifier.VideoQualityChanged import org.openedx.course.R import org.openedx.course.domain.interactor.CourseInteractor import org.openedx.course.presentation.CourseAnalytics @@ -29,7 +33,8 @@ class CourseVideoViewModel( private val resourceManager: ResourceManager, private val networkConnection: NetworkConnection, private val preferencesManager: CorePreferences, - private val notifier: CourseNotifier, + private val courseNotifier: CourseNotifier, + private val videoNotifier: VideoNotifier, private val analytics: CourseAnalytics, downloadDao: DownloadDao, workerController: DownloadWorkerController @@ -55,6 +60,9 @@ class CourseVideoViewModel( val uiMessage: LiveData get() = _uiMessage + private val _videoSettings = MutableStateFlow(VideoSettings.default) + val videoSettings = _videoSettings.asStateFlow() + val hasInternetConnection: Boolean get() = networkConnection.isOnline() @@ -62,10 +70,9 @@ class CourseVideoViewModel( private val subSectionsDownloadsCount = mutableMapOf() val courseSubSectionUnit = mutableMapOf() - override fun onCreate(owner: LifecycleOwner) { - super.onCreate(owner) + init { viewModelScope.launch { - notifier.notifier.collect { event -> + courseNotifier.notifier.collect { event -> if (event is CourseStructureUpdated) { if (event.courseId == courseId) { updateVideos() @@ -78,20 +85,32 @@ class CourseVideoViewModel( downloadModelsStatusFlow.collect { if (_uiState.value is CourseVideosUIState.CourseData) { val state = _uiState.value as CourseVideosUIState.CourseData - _uiState.value = CourseVideosUIState.CourseData( - courseStructure = state.courseStructure, + _uiState.value = state.copy( downloadedState = it.toMap(), - courseSubSections = courseSubSections, - courseSectionsState = state.courseSectionsState, - subSectionsDownloadsCount = subSectionsDownloadsCount + downloadModelsSize = getDownloadModelsSize() ) } } } - } - init { + viewModelScope.launch { + videoNotifier.notifier.collect { event -> + if (event is VideoQualityChanged) { + _videoSettings.value = preferencesManager.videoSettings + + if (_uiState.value is CourseVideosUIState.CourseData) { + val state = _uiState.value as CourseVideosUIState.CourseData + _uiState.value = state.copy( + downloadModelsSize = getDownloadModelsSize() + ) + } + } + } + } + getVideos() + + _videoSettings.value = preferencesManager.videoSettings } override fun saveDownloadModels(folder: String, id: String) { @@ -107,6 +126,16 @@ class CourseVideoViewModel( } } + override fun saveAllDownloadModels(folder: String) { + if (preferencesManager.videoSettings.wifiDownloadOnly && !networkConnection.isWifiConnected()) { + _uiMessage.value = + UIMessage.ToastMessage(resourceManager.getString(R.string.course_can_download_only_with_wifi)) + return + } + + super.saveAllDownloadModels(folder) + } + fun setIsUpdating() { _isUpdating.value = true } @@ -137,7 +166,7 @@ class CourseVideoViewModel( _uiState.value = CourseVideosUIState.CourseData( courseStructure, getDownloadModelsStatus(), courseSubSections, - courseSectionsState, subSectionsDownloadsCount + courseSectionsState, subSectionsDownloadsCount, getDownloadModelsSize() ) } } @@ -149,13 +178,7 @@ class CourseVideoViewModel( val courseSectionsState = state.courseSectionsState.toMutableMap() courseSectionsState[blockId] = !(state.courseSectionsState[blockId] ?: false) - _uiState.value = CourseVideosUIState.CourseData( - courseStructure = state.courseStructure, - downloadedState = state.downloadedState, - courseSubSections = courseSubSections, - courseSectionsState = courseSectionsState, - subSectionsDownloadsCount = subSectionsDownloadsCount - ) + _uiState.value = state.copy(courseSectionsState = courseSectionsState) } } @@ -166,6 +189,12 @@ class CourseVideoViewModel( } } + fun onChangingVideoQualityWhileDownloading() { + _uiMessage.value = UIMessage.SnackBarMessage( + resourceManager.getString(R.string.course_change_quality_when_downloading) + ) + } + private fun sortBlocks(blocks: List): List { val resultBlocks = mutableListOf() if (blocks.isEmpty()) return emptyList() @@ -190,4 +219,4 @@ class CourseVideoViewModel( } return resultBlocks.toList() } -} \ No newline at end of file +} diff --git a/course/src/main/java/org/openedx/course/presentation/videos/CourseVideosFragment.kt b/course/src/main/java/org/openedx/course/presentation/videos/CourseVideosFragment.kt index 97d7c8747..1d6f258f1 100644 --- a/course/src/main/java/org/openedx/course/presentation/videos/CourseVideosFragment.kt +++ b/course/src/main/java/org/openedx/course/presentation/videos/CourseVideosFragment.kt @@ -1,56 +1,27 @@ package org.openedx.course.presentation.videos -import android.content.res.Configuration import android.os.Bundle import android.view.LayoutInflater import android.view.ViewGroup -import androidx.compose.animation.AnimatedVisibility -import androidx.compose.foundation.layout.* -import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.items -import androidx.compose.material.* -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.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.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.android.ext.android.inject import org.koin.androidx.viewmodel.ext.android.viewModel import org.koin.core.parameter.parametersOf -import org.openedx.core.BlockType import org.openedx.core.R -import org.openedx.core.UIMessage -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.ui.* +import org.openedx.core.presentation.settings.VideoQualityType +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.course.presentation.CourseRouter import org.openedx.course.presentation.container.CourseContainerFragment -import org.openedx.course.presentation.ui.CourseExpandableChapterCard -import org.openedx.course.presentation.ui.CourseImageHeader -import org.openedx.course.presentation.ui.CourseSectionCard -import org.openedx.course.presentation.ui.CourseSubSectionItem +import org.openedx.course.presentation.ui.CourseVideosScreen import java.io.File -import java.util.Date class CourseVideosFragment : Fragment() { @@ -80,16 +51,19 @@ class CourseVideosFragment : Fragment() { val uiState by viewModel.uiState.observeAsState(CourseVideosUIState.Loading) val uiMessage by viewModel.uiMessage.observeAsState() val isUpdating by viewModel.isUpdating.observeAsState(false) + val videoSettings by viewModel.videoSettings.collectAsState() CourseVideosScreen( windowSize = windowSize, uiState = uiState, uiMessage = uiMessage, + courseTitle = viewModel.courseTitle, apiHostUrl = viewModel.apiHostUrl, isCourseNestedListEnabled = viewModel.isCourseNestedListEnabled, isCourseBannerEnabled = viewModel.isCourseBannerEnabled, hasInternetConnection = viewModel.hasInternetConnection, isUpdating = isUpdating, + videoSettings = videoSettings, onSwipeRefresh = { viewModel.setIsUpdating() (parentFragment as CourseContainerFragment).updateCourseStructure(true) @@ -121,9 +95,12 @@ class CourseVideosFragment : Fragment() { }, onDownloadClick = { if (viewModel.isBlockDownloading(it.id)) { - viewModel.cancelWork(it.id) + router.navigateToDownloadQueue( + fm = requireActivity().supportFragmentManager, + viewModel.getDownloadableChildren(it.id) ?: arrayListOf() + ) } else if (viewModel.isBlockDownloaded(it.id)) { - viewModel.removeDownloadedModels(it.id) + viewModel.removeDownloadModels(it.id) } else { viewModel.saveDownloadModels( requireContext().externalCacheDir.toString() + @@ -133,6 +110,33 @@ class CourseVideosFragment : Fragment() { .replace(Regex("\\s"), "_"), it.id ) } + }, + onDownloadAllClick = { isAllBlocksDownloadedOrDownloading -> + if (isAllBlocksDownloadedOrDownloading) { + viewModel.removeAllDownloadModels() + } else { + viewModel.saveAllDownloadModels( + requireContext().externalCacheDir.toString() + + File.separator + + requireContext() + .getString(R.string.app_name) + .replace(Regex("\\s"), "_") + ) + } + }, + onDownloadQueueClick = { + if (viewModel.hasDownloadModelsInQueue()) { + router.navigateToDownloadQueue(fm = requireActivity().supportFragmentManager) + } + }, + onVideoDownloadQualityClick = { + if (viewModel.hasDownloadModelsInQueue()) { + viewModel.onChangingVideoQualityWhileDownloading() + } else { + router.navigateToVideoQuality( + requireActivity().supportFragmentManager, VideoQualityType.Download + ) + } } ) } @@ -156,376 +160,3 @@ class CourseVideosFragment : Fragment() { } } -@OptIn(ExperimentalMaterialApi::class) -@Composable -private fun CourseVideosScreen( - windowSize: WindowSize, - uiState: CourseVideosUIState, - uiMessage: UIMessage?, - apiHostUrl: String, - isCourseNestedListEnabled: Boolean, - isCourseBannerEnabled: Boolean, - isUpdating: Boolean, - hasInternetConnection: Boolean, - onSwipeRefresh: () -> Unit, - onItemClick: (Block) -> Unit, - onExpandClick: (Block) -> Unit, - onSubSectionClick: (Block) -> Unit, - onReloadClick: () -> Unit, - onDownloadClick: (Block) -> Unit -) { - val scaffoldState = rememberScaffoldState() - val pullRefreshState = - rememberPullRefreshState(refreshing = isUpdating, onRefresh = { onSwipeRefresh() }) - - var isInternetConnectionShown by rememberSaveable { - mutableStateOf(false) - } - - Scaffold( - modifier = Modifier - .fillMaxSize(), - 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() - ) - ) - } - - val listBottomPadding by remember(key1 = windowSize) { - mutableStateOf( - windowSize.windowSizeValue( - expanded = PaddingValues(bottom = 24.dp), - compact = PaddingValues(bottom = 24.dp) - ) - ) - } - - val listPadding by remember(key1 = windowSize) { - mutableStateOf( - windowSize.windowSizeValue( - expanded = Modifier.padding(horizontal = 6.dp), - compact = Modifier.padding(horizontal = 24.dp) - ) - ) - } - - HandleUIMessage(uiMessage = uiMessage, scaffoldState = scaffoldState) - - Box( - modifier = Modifier - .fillMaxSize() - .padding(it) - .displayCutoutForLandscape(), - contentAlignment = Alignment.TopCenter - ) { - Surface( - modifier = screenWidth, - color = MaterialTheme.appColors.background, - shape = MaterialTheme.appShapes.screenBackgroundShape - ) { - Box(Modifier.pullRefresh(pullRefreshState)) { - Column( - Modifier - .fillMaxSize() - ) { - when (uiState) { - is CourseVideosUIState.Empty -> { - Box( - modifier = Modifier.fillMaxSize(), - contentAlignment = Alignment.Center - ) { - Text( - text = stringResource(id = org.openedx.course.R.string.course_does_not_include_videos), - color = MaterialTheme.appColors.textPrimary, - style = MaterialTheme.appTypography.headlineSmall, - textAlign = TextAlign.Center, - modifier = Modifier.padding(horizontal = 40.dp) - ) - } - } - - is CourseVideosUIState.Loading -> { - Box( - modifier = Modifier.fillMaxSize(), - contentAlignment = Alignment.Center - ) { - CircularProgressIndicator(color = MaterialTheme.appColors.primary) - } - } - - is CourseVideosUIState.CourseData -> { - LazyColumn( - modifier = Modifier.fillMaxSize(), - contentPadding = listBottomPadding - ) { - if (isCourseBannerEnabled) { - item { - CourseImageHeader( - modifier = Modifier - .aspectRatio(1.86f) - .padding(6.dp), - apiHostUrl = apiHostUrl, - courseImage = uiState.courseStructure.media?.image?.large - ?: "", - courseCertificate = uiState.courseStructure.certificate, - courseName = uiState.courseStructure.name - ) - } - } - - if (isCourseNestedListEnabled) { - item { - Spacer(Modifier.height(16.dp)) - } - uiState.courseStructure.blockData.forEach { section -> - val courseSubSections = - uiState.courseSubSections[section.id] - val courseSectionsState = - uiState.courseSectionsState[section.id] - - item { - Column { - CourseExpandableChapterCard( - modifier = listPadding, - block = section, - onItemClick = onExpandClick, - arrowDegrees = if (courseSectionsState == true) -90f else 90f - ) - Divider() - } - } - - courseSubSections?.forEach { subSectionBlock -> - item { - Column { - AnimatedVisibility( - visible = courseSectionsState == true - ) { - Column { - val downloadsCount = - uiState.subSectionsDownloadsCount[subSectionBlock.id] - ?: 0 - - CourseSubSectionItem( - modifier = listPadding, - block = subSectionBlock, - downloadedState = uiState.downloadedState[subSectionBlock.id], - downloadsCount = downloadsCount, - onClick = onSubSectionClick, - onDownloadClick = onDownloadClick - ) - Divider() - } - } - } - } - } - } - return@LazyColumn - } - - items(uiState.courseStructure.blockData) { block -> - Column(listPadding) { - if (block.type == BlockType.CHAPTER) { - Text( - modifier = Modifier.padding( - top = 36.dp, - bottom = 8.dp - ), - text = block.displayName, - style = MaterialTheme.appTypography.titleMedium, - color = MaterialTheme.appColors.textPrimaryVariant - ) - } else { - CourseSectionCard( - block = block, - downloadedState = uiState.downloadedState[block.id], - onItemClick = onItemClick, - onDownloadClick = onDownloadClick - ) - Divider() - } - } - } - } - } - } - } - PullRefreshIndicator( - isUpdating, - pullRefreshState, - Modifier.align(Alignment.TopCenter) - ) - if (!isInternetConnectionShown && !hasInternetConnection) { - OfflineModeDialog( - Modifier - .fillMaxWidth() - .align(Alignment.BottomCenter), - onDismissCLick = { - isInternetConnectionShown = true - }, - onReloadClick = { - isInternetConnectionShown = true - onReloadClick() - } - ) - } - } - } - } - } -} - -@Preview(uiMode = Configuration.UI_MODE_NIGHT_NO) -@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES) -@Composable -private fun CourseVideosScreenPreview() { - OpenEdXTheme { - CourseVideosScreen( - windowSize = WindowSize(WindowType.Compact, WindowType.Compact), - uiMessage = null, - uiState = CourseVideosUIState.CourseData( - mockCourseStructure, - emptyMap(), - mapOf(), - mapOf(), - mapOf() - ), - apiHostUrl = "", - isCourseNestedListEnabled = false, - isCourseBannerEnabled = true, - onItemClick = { }, - onExpandClick = { }, - onSubSectionClick = { }, - hasInternetConnection = true, - isUpdating = false, - onSwipeRefresh = {}, - onReloadClick = {}, - onDownloadClick = {} - ) - } -} - -@Preview(uiMode = Configuration.UI_MODE_NIGHT_NO) -@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES) -@Composable -private fun CourseVideosScreenEmptyPreview() { - OpenEdXTheme { - CourseVideosScreen( - windowSize = WindowSize(WindowType.Compact, WindowType.Compact), - uiMessage = null, - uiState = CourseVideosUIState.Empty( - "This course does not include any videos." - ), - apiHostUrl = "", - isCourseNestedListEnabled = false, - isCourseBannerEnabled = true, - onItemClick = { }, - onExpandClick = { }, - onSubSectionClick = { }, - onSwipeRefresh = {}, - onReloadClick = {}, - hasInternetConnection = true, - isUpdating = false, - onDownloadClick = {} - ) - } -} - -@Preview(uiMode = Configuration.UI_MODE_NIGHT_NO, device = Devices.NEXUS_9) -@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES, device = Devices.NEXUS_9) -@Composable -private fun CourseVideosScreenTabletPreview() { - OpenEdXTheme { - CourseVideosScreen( - windowSize = WindowSize(WindowType.Medium, WindowType.Medium), - uiMessage = null, - uiState = CourseVideosUIState.CourseData( - mockCourseStructure, - emptyMap(), - mapOf(), - mapOf(), - mapOf() - ), - apiHostUrl = "", - isCourseNestedListEnabled = false, - isCourseBannerEnabled = true, - onItemClick = { }, - onExpandClick = { }, - onSubSectionClick = { }, - onSwipeRefresh = {}, - onReloadClick = {}, - isUpdating = false, - hasInternetConnection = true, - onDownloadClick = {} - ) - } -} - - -private val mockChapterBlock = Block( - id = "id", - blockId = "blockId", - lmsWebUrl = "lmsWebUrl", - legacyWebUrl = "legacyWebUrl", - studentViewUrl = "studentViewUrl", - type = BlockType.CHAPTER, - displayName = "Chapter", - graded = false, - studentViewData = null, - studentViewMultiDevice = false, - blockCounts = BlockCounts(1), - descendants = emptyList(), - descendantsType = BlockType.CHAPTER, - completion = 0.0, - containsGatedContent = false -) - -private val mockSequentialBlock = Block( - id = "id", - blockId = "blockId", - lmsWebUrl = "lmsWebUrl", - legacyWebUrl = "legacyWebUrl", - studentViewUrl = "studentViewUrl", - type = BlockType.SEQUENTIAL, - displayName = "Sequential", - graded = false, - studentViewData = null, - studentViewMultiDevice = false, - blockCounts = BlockCounts(1), - descendants = emptyList(), - descendantsType = BlockType.SEQUENTIAL, - completion = 0.0, - containsGatedContent = false -) - -private val mockCourseStructure = CourseStructure( - root = "", - blockData = listOf(mockSequentialBlock, mockChapterBlock), - id = "id", - name = "Course name", - number = "", - org = "Org", - start = Date(), - startDisplay = "", - startType = "", - end = Date(), - coursewareAccess = CoursewareAccess( - true, - "", - "", - "", - "", - "" - ), - media = null, - certificate = null, - isSelfPaced = false -) \ No newline at end of file diff --git a/course/src/main/java/org/openedx/course/presentation/videos/CourseVideosUIState.kt b/course/src/main/java/org/openedx/course/presentation/videos/CourseVideosUIState.kt index 72289c7db..ce05913d6 100644 --- a/course/src/main/java/org/openedx/course/presentation/videos/CourseVideosUIState.kt +++ b/course/src/main/java/org/openedx/course/presentation/videos/CourseVideosUIState.kt @@ -3,6 +3,7 @@ package org.openedx.course.presentation.videos import org.openedx.core.domain.model.Block import org.openedx.core.domain.model.CourseStructure import org.openedx.core.module.db.DownloadedState +import org.openedx.core.module.download.DownloadModelsSize sealed class CourseVideosUIState { data class CourseData( @@ -10,9 +11,10 @@ sealed class CourseVideosUIState { val downloadedState: Map, val courseSubSections: Map>, val courseSectionsState: Map, - val subSectionsDownloadsCount: Map + val subSectionsDownloadsCount: Map, + val downloadModelsSize: DownloadModelsSize ) : CourseVideosUIState() data class Empty(val message: String) : CourseVideosUIState() object Loading : CourseVideosUIState() -} \ No newline at end of file +} diff --git a/course/src/main/java/org/openedx/course/settings/download/DownloadQueueFragment.kt b/course/src/main/java/org/openedx/course/settings/download/DownloadQueueFragment.kt new file mode 100644 index 000000000..5e50ecf39 --- /dev/null +++ b/course/src/main/java/org/openedx/course/settings/download/DownloadQueueFragment.kt @@ -0,0 +1,254 @@ +package org.openedx.course.settings.download + +import android.content.res.Configuration +import android.os.Bundle +import android.view.LayoutInflater +import android.view.ViewGroup +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.navigationBarsPadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material.CircularProgressIndicator +import androidx.compose.material.Divider +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.collectAsState +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.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.zIndex +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.module.db.DownloadModel +import org.openedx.core.module.db.DownloadedState +import org.openedx.core.module.db.FileType +import org.openedx.core.ui.BackBtn +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.openedx.core.ui.windowSizeValue +import org.openedx.course.R +import org.openedx.course.presentation.ui.OfflineQueueCard + +class DownloadQueueFragment : Fragment() { + + private val viewModel by viewModel { + parametersOf(requireArguments().getStringArrayList(ARG_DESCENDANTS)) + } + + 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.collectAsState(DownloadQueueUIState.Loading) + + DownloadQueueScreen( + windowSize = windowSize, + uiState = uiState, + onBackClick = { + requireActivity().supportFragmentManager.popBackStack() + }, + onDownloadClick = { + viewModel.removeDownloadModels(it.id) + } + ) + } + } + } + + companion object { + private const val ARG_DESCENDANTS = "descendants" + fun newInstance(descendants: List): DownloadQueueFragment { + val fragment = DownloadQueueFragment() + fragment.arguments = bundleOf( + ARG_DESCENDANTS to descendants + ) + return fragment + } + } +} + +@Composable +private fun DownloadQueueScreen( + windowSize: WindowSize, + uiState: DownloadQueueUIState, + onBackClick: () -> Unit, + onDownloadClick: (DownloadModel) -> Unit +) { + val scaffoldState = rememberScaffoldState() + + Scaffold( + modifier = Modifier + .fillMaxSize() + .navigationBarsPadding(), + scaffoldState = scaffoldState, + backgroundColor = MaterialTheme.appColors.background + ) { paddingValues -> + + val contentWidth by remember(key1 = windowSize) { + mutableStateOf( + windowSize.windowSizeValue( + expanded = Modifier.widthIn(Dp.Unspecified, 560.dp), + compact = Modifier.fillMaxWidth() + ) + ) + } + + Box( + modifier = Modifier + .fillMaxSize() + .padding(paddingValues) + .displayCutoutForLandscape(), + contentAlignment = Alignment.TopCenter + ) { + Column(contentWidth) { + Box( + Modifier + .fillMaxWidth() + .statusBarsInset() + .zIndex(1f), + contentAlignment = Alignment.CenterStart + ) { + BackBtn { + onBackClick() + } + Text( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 56.dp), + text = stringResource(id = R.string.course_download_queue_title), + color = MaterialTheme.appColors.textPrimary, + style = MaterialTheme.appTypography.titleMedium, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + textAlign = TextAlign.Center + ) + } + Spacer(Modifier.height(6.dp)) + Surface( + color = MaterialTheme.appColors.background, + shape = MaterialTheme.appShapes.screenBackgroundShape + ) { + when (uiState) { + is DownloadQueueUIState.Loading -> { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center + ) { + CircularProgressIndicator(color = MaterialTheme.appColors.primary) + } + } + + is DownloadQueueUIState.Models -> { + Column(Modifier.fillMaxSize()) { + LazyColumn { + items(uiState.downloadingModels) { model -> + val progressValue = + if (model.id == uiState.currentProgressId) + uiState.currentProgressValue else 0 + val progressSize = + if (model.id == uiState.currentProgressId) + uiState.currentProgressSize else 0 + + OfflineQueueCard( + downloadModel = model, + progressValue = progressValue, + progressSize = progressSize, + onDownloadClick = onDownloadClick + ) + Divider() + } + } + } + } + + else -> { + onBackClick() + } + } + } + } + } + } +} + + +@Preview(uiMode = Configuration.UI_MODE_NIGHT_NO, device = Devices.TABLET) +@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES) +@Composable +private fun DownloadQueueScreenPreview() { + OpenEdXTheme { + DownloadQueueScreen( + windowSize = WindowSize(WindowType.Compact, WindowType.Compact), + uiState = DownloadQueueUIState.Models( + listOf( + DownloadModel( + id = "", + title = "1", + size = 0, + path = "", + url = "", + type = FileType.VIDEO, + downloadedState = DownloadedState.DOWNLOADING, + progress = 0f + ), + DownloadModel( + id = "", + title = "2", + size = 0, + path = "", + url = "", + type = FileType.VIDEO, + downloadedState = DownloadedState.DOWNLOADING, + progress = 0f + ) + ), + currentProgressId = "", + currentProgressValue = 0, + currentProgressSize = 1 + ), + onBackClick = {}, + onDownloadClick = {} + ) + } +} diff --git a/course/src/main/java/org/openedx/course/settings/download/DownloadQueueUIState.kt b/course/src/main/java/org/openedx/course/settings/download/DownloadQueueUIState.kt new file mode 100644 index 000000000..0ebcc45e3 --- /dev/null +++ b/course/src/main/java/org/openedx/course/settings/download/DownloadQueueUIState.kt @@ -0,0 +1,16 @@ +package org.openedx.course.settings.download + +import org.openedx.core.module.db.DownloadModel + +sealed class DownloadQueueUIState { + data class Models( + val downloadingModels: List, + val currentProgressId: String, + val currentProgressValue: Long, + val currentProgressSize: Long + ) : DownloadQueueUIState() + + object Loading : DownloadQueueUIState() + + object Empty : DownloadQueueUIState() +} diff --git a/course/src/main/java/org/openedx/course/settings/download/DownloadQueueViewModel.kt b/course/src/main/java/org/openedx/course/settings/download/DownloadQueueViewModel.kt new file mode 100644 index 000000000..9be72dd8c --- /dev/null +++ b/course/src/main/java/org/openedx/course/settings/download/DownloadQueueViewModel.kt @@ -0,0 +1,72 @@ +package org.openedx.course.settings.download + +import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch +import org.openedx.core.data.storage.CorePreferences +import org.openedx.core.module.DownloadWorkerController +import org.openedx.core.module.db.DownloadDao +import org.openedx.core.module.download.BaseDownloadViewModel +import org.openedx.core.system.notifier.DownloadNotifier +import org.openedx.core.system.notifier.DownloadProgressChanged + +class DownloadQueueViewModel( + private val descendants: List, + downloadDao: DownloadDao, + preferencesManager: CorePreferences, + private val workerController: DownloadWorkerController, + private val downloadNotifier: DownloadNotifier +) : BaseDownloadViewModel(downloadDao, preferencesManager, workerController) { + + private val _uiState = MutableStateFlow(DownloadQueueUIState.Loading) + val uiState = _uiState.asStateFlow() + + init { + viewModelScope.launch { + downloadingModelsFlow.collect { models -> + val filteredModels = + if (descendants.isEmpty()) models else models.filter { descendants.contains(it.id) } + if (filteredModels.isEmpty()) { + _uiState.value = DownloadQueueUIState.Empty + + } else { + if (_uiState.value is DownloadQueueUIState.Models) { + val state = _uiState.value as DownloadQueueUIState.Models + _uiState.value = state.copy( + downloadingModels = filteredModels + ) + } else { + _uiState.value = DownloadQueueUIState.Models( + downloadingModels = filteredModels, + currentProgressId = "", + currentProgressValue = 0L, + currentProgressSize = 0L + ) + } + } + } + } + + viewModelScope.launch { + downloadNotifier.notifier.collect { event -> + if (event is DownloadProgressChanged) { + if (_uiState.value is DownloadQueueUIState.Models) { + val state = _uiState.value as DownloadQueueUIState.Models + _uiState.value = state.copy( + currentProgressId = event.id, + currentProgressValue = event.value, + currentProgressSize = event.size + ) + } + } + } + } + } + + override fun removeDownloadModels(blockId: String) { + viewModelScope.launch { + workerController.removeModel(blockId) + } + } +} diff --git a/course/src/main/res/menu/bottom_course_container_menu.xml b/course/src/main/res/menu/bottom_course_container_menu.xml index da9eee1b9..97529a580 100644 --- a/course/src/main/res/menu/bottom_course_container_menu.xml +++ b/course/src/main/res/menu/bottom_course_container_menu.xml @@ -9,13 +9,13 @@ @@ -31,4 +31,4 @@ android:enabled="true" android:icon="@drawable/ic_course_navigation_more"/> - \ No newline at end of file + diff --git a/course/src/main/res/values-uk/strings.xml b/course/src/main/res/values-uk/strings.xml index 3fa3fd87d..28f5a4628 100644 --- a/course/src/main/res/values-uk/strings.xml +++ b/course/src/main/res/values-uk/strings.xml @@ -35,8 +35,8 @@ Цей курс ще не розпочався. Ви не підключені до Інтернету. Будь ласка, перевірте ваше підключення до Інтернету. Курс - Відео - Обговорення + Відео + Обговорення Матеріали Ви можете завантажувати контент тільки через Wi-Fi Ця інтерактивна компонента ще не доступна diff --git a/course/src/main/res/values/strings.xml b/course/src/main/res/values/strings.xml index 32a6875fa..9b22e4c95 100644 --- a/course/src/main/res/values/strings.xml +++ b/course/src/main/res/values/strings.xml @@ -35,8 +35,8 @@ This course hasn’t started yet. You are not connected to the Internet. Please check your Internet connection. Course - Video - Discussion + Videos + Discussions Handouts You can download content only from Wi-fi This interactive component isn’t yet available @@ -50,6 +50,8 @@ Dates You are already enrolled in this course. Discover + You cannot change the download video quality when all videos are downloading + Course dates are not currently available. @@ -62,4 +64,13 @@ Stop downloading course section Section completed Section uncompleted + + Downloads + (Untitled) + Download + The videos you\'ve selected are larger than 1 GB. Do you want to download these videos? + Turning off the switch will stop downloading and delete all downloaded videos for \"%s\"? + Are you sure you want to delete all video(s) for \"%s\"? + Are you sure you want to delete video(s) for \"%s\"? + 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 e26cb019f..6f129106c 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 @@ -385,7 +385,7 @@ class CourseOutlineViewModelTest { every { interactor.getCourseStructureFromCache() } returns courseStructure every { networkConnection.isWifiConnected() } returns true every { networkConnection.isOnline() } returns true - coEvery { workerController.saveModels(*anyVararg()) } returns Unit + coEvery { workerController.saveModels(any()) } returns Unit coEvery { interactor.getCourseStatus(any()) } returns CourseComponentStatus("id") coEvery { downloadDao.readAllData() } returns flow { emit(emptyList()) } every { config.isCourseNestedListEnabled() } returns false @@ -418,7 +418,7 @@ class CourseOutlineViewModelTest { every { networkConnection.isWifiConnected() } returns true every { networkConnection.isOnline() } returns true coEvery { downloadDao.readAllData() } returns mockk() - coEvery { workerController.saveModels(*anyVararg()) } returns Unit + coEvery { workerController.saveModels(any()) } returns Unit coEvery { downloadDao.readAllData() } returns flow { emit(emptyList()) } every { config.isCourseNestedListEnabled() } returns false coEvery { interactor.getDatesBannerInfo(any()) } returns mockCourseDatesBannerInfo @@ -448,7 +448,7 @@ class CourseOutlineViewModelTest { every { preferencesManager.videoSettings.wifiDownloadOnly } returns true every { networkConnection.isWifiConnected() } returns false every { networkConnection.isOnline() } returns false - coEvery { workerController.saveModels(*anyVararg()) } returns Unit + coEvery { workerController.saveModels(any()) } returns Unit coEvery { downloadDao.readAllData() } returns flow { emit(emptyList()) } every { config.isCourseNestedListEnabled() } returns false @@ -473,4 +473,4 @@ class CourseOutlineViewModelTest { assert(!viewModel.hasInternetConnection) } -} \ No newline at end of file +} 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 db3309093..d2f8a0b6b 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 @@ -260,7 +260,10 @@ class CourseSectionViewModelTest { ) every { preferencesManager.videoSettings.wifiDownloadOnly } returns false every { networkConnection.isWifiConnected() } returns true - coEvery { workerController.saveModels(*anyVararg()) } returns Unit + coEvery { workerController.saveModels(any()) } returns Unit + coEvery { downloadDao.readAllData() } returns flow { + emit(listOf(DownloadModelEntity.createFrom(downloadModel))) + } viewModel.saveDownloadModels("", "") advanceUntilIdle() @@ -283,7 +286,10 @@ class CourseSectionViewModelTest { ) every { preferencesManager.videoSettings.wifiDownloadOnly } returns true every { networkConnection.isWifiConnected() } returns true - coEvery { workerController.saveModels(*anyVararg()) } returns Unit + coEvery { workerController.saveModels(any()) } returns Unit + coEvery { downloadDao.readAllData() } returns flow { + emit(listOf(DownloadModelEntity.createFrom(downloadModel))) + } viewModel.saveDownloadModels("", "") advanceUntilIdle() @@ -307,7 +313,7 @@ class CourseSectionViewModelTest { every { preferencesManager.videoSettings.wifiDownloadOnly } returns true every { networkConnection.isWifiConnected() } returns false every { networkConnection.isOnline() } returns false - coEvery { workerController.saveModels(*anyVararg()) } returns Unit + coEvery { workerController.saveModels(any()) } returns Unit viewModel.saveDownloadModels("", "") @@ -353,4 +359,4 @@ class CourseSectionViewModelTest { } -} \ No newline at end of file +} 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 9057df980..d898c5f4b 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,12 +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 io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.every +import io.mockk.mockk +import io.mockk.spyk import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.delay import kotlinx.coroutines.flow.flow -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.Before import org.junit.Rule @@ -22,17 +30,22 @@ 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.domain.model.VideoSettings import org.openedx.core.module.DownloadWorkerController import org.openedx.core.module.db.DownloadDao +import org.openedx.core.module.db.DownloadModel import org.openedx.core.module.db.DownloadModelEntity +import org.openedx.core.module.db.DownloadedState +import org.openedx.core.module.db.FileType 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.core.system.notifier.VideoNotifier import org.openedx.course.R import org.openedx.course.domain.interactor.CourseInteractor import org.openedx.course.presentation.CourseAnalytics -import java.util.* +import java.util.Date @OptIn(ExperimentalCoroutinesApi::class) class CourseVideoViewModelTest { @@ -44,7 +57,8 @@ class CourseVideoViewModelTest { private val config = mockk() private val resourceManager = mockk() private val interactor = mockk() - private val notifier = spyk() + private val courseNotifier = spyk() + private val videoNotifier = spyk() private val analytics = mockk() private val preferencesManager = mockk() private val networkConnection = mockk() @@ -131,6 +145,16 @@ class CourseVideoViewModelTest { private val downloadModelEntity = DownloadModelEntity("", "", 1, "", "", "VIDEO", "DOWNLOADED", null) + private val downloadModel = DownloadModel( + "id", + "title", + 0, + "", + "url", + FileType.VIDEO, + DownloadedState.NOT_DOWNLOADED, + null + ) @Before fun setUp() { @@ -150,7 +174,7 @@ class CourseVideoViewModelTest { every { config.isCourseNestedListEnabled() } returns false every { interactor.getCourseStructureForVideos() } returns courseStructure.copy(blockData = emptyList()) every { downloadDao.readAllData() } returns flow { emit(emptyList()) } - + every { preferencesManager.videoSettings } returns VideoSettings.default val viewModel = CourseVideoViewModel( "", config, @@ -158,7 +182,8 @@ class CourseVideoViewModelTest { resourceManager, networkConnection, preferencesManager, - notifier, + courseNotifier, + videoNotifier, analytics, downloadDao, workerController @@ -177,6 +202,8 @@ class CourseVideoViewModelTest { every { config.isCourseNestedListEnabled() } returns false every { interactor.getCourseStructureForVideos() } returns courseStructure every { downloadDao.readAllData() } returns flow { emit(emptyList()) } + every { preferencesManager.videoSettings } returns VideoSettings.default + val viewModel = CourseVideoViewModel( "", config, @@ -184,7 +211,8 @@ class CourseVideoViewModelTest { resourceManager, networkConnection, preferencesManager, - notifier, + courseNotifier, + videoNotifier, analytics, downloadDao, workerController @@ -203,13 +231,14 @@ class CourseVideoViewModelTest { fun `updateVideos success`() = runTest { every { config.isCourseNestedListEnabled() } returns false every { interactor.getCourseStructureForVideos() } returns courseStructure - coEvery { notifier.notifier } returns flow { emit(CourseStructureUpdated("", false)) } + coEvery { courseNotifier.notifier } returns flow { emit(CourseStructureUpdated("", false)) } every { downloadDao.readAllData() } returns flow { repeat(5) { delay(10000) emit(emptyList()) } } + every { preferencesManager.videoSettings } returns VideoSettings.default val viewModel = CourseVideoViewModel( "", config, @@ -217,7 +246,8 @@ class CourseVideoViewModelTest { resourceManager, networkConnection, preferencesManager, - notifier, + courseNotifier, + videoNotifier, analytics, downloadDao, workerController @@ -239,6 +269,7 @@ class CourseVideoViewModelTest { @Test fun `setIsUpdating success`() = runTest { every { config.isCourseNestedListEnabled() } returns false + every { preferencesManager.videoSettings } returns VideoSettings.default val viewModel = CourseVideoViewModel( "", config, @@ -246,7 +277,8 @@ class CourseVideoViewModelTest { resourceManager, networkConnection, preferencesManager, - notifier, + courseNotifier, + videoNotifier, analytics, downloadDao, workerController @@ -262,6 +294,7 @@ class CourseVideoViewModelTest { @Test fun `saveDownloadModels test`() = runTest { every { config.isCourseNestedListEnabled() } returns false + every { preferencesManager.videoSettings } returns VideoSettings.default val viewModel = CourseVideoViewModel( "", config, @@ -269,7 +302,8 @@ class CourseVideoViewModelTest { resourceManager, networkConnection, preferencesManager, - notifier, + courseNotifier, + videoNotifier, analytics, downloadDao, workerController @@ -278,7 +312,7 @@ class CourseVideoViewModelTest { coEvery { downloadDao.readAllData() } returns flow { emit(listOf(downloadModelEntity)) } every { preferencesManager.videoSettings.wifiDownloadOnly } returns false every { networkConnection.isWifiConnected() } returns true - coEvery { workerController.saveModels(*anyVararg()) } returns Unit + coEvery { workerController.saveModels(any()) } returns Unit viewModel.saveDownloadModels("", "") advanceUntilIdle() @@ -289,6 +323,7 @@ class CourseVideoViewModelTest { @Test fun `saveDownloadModels only wifi download, with connection`() = runTest { every { config.isCourseNestedListEnabled() } returns false + every { preferencesManager.videoSettings } returns VideoSettings.default val viewModel = CourseVideoViewModel( "", config, @@ -296,7 +331,8 @@ class CourseVideoViewModelTest { resourceManager, networkConnection, preferencesManager, - notifier, + courseNotifier, + videoNotifier, analytics, downloadDao, workerController @@ -305,7 +341,10 @@ class CourseVideoViewModelTest { coEvery { downloadDao.readAllData() } returns flow { emit(listOf(downloadModelEntity)) } every { preferencesManager.videoSettings.wifiDownloadOnly } returns true every { networkConnection.isWifiConnected() } returns true - coEvery { workerController.saveModels(*anyVararg()) } returns Unit + coEvery { workerController.saveModels(any()) } returns Unit + coEvery { downloadDao.readAllData() } returns flow { + emit(listOf(DownloadModelEntity.createFrom(downloadModel))) + } viewModel.saveDownloadModels("", "") advanceUntilIdle() @@ -316,6 +355,7 @@ class CourseVideoViewModelTest { @Test fun `saveDownloadModels only wifi download, without conection`() = runTest { every { config.isCourseNestedListEnabled() } returns false + every { preferencesManager.videoSettings } returns VideoSettings.default val viewModel = CourseVideoViewModel( "", config, @@ -323,7 +363,8 @@ class CourseVideoViewModelTest { resourceManager, networkConnection, preferencesManager, - notifier, + courseNotifier, + videoNotifier, analytics, downloadDao, workerController @@ -333,7 +374,7 @@ class CourseVideoViewModelTest { every { networkConnection.isOnline() } returns false coEvery { interactor.getCourseStructureForVideos() } returns courseStructure coEvery { downloadDao.readAllData() } returns flow { emit(listOf(downloadModelEntity)) } - coEvery { workerController.saveModels(*anyVararg()) } returns Unit + coEvery { workerController.saveModels(any()) } returns Unit viewModel.saveDownloadModels("", "") @@ -344,4 +385,4 @@ class CourseVideoViewModelTest { } -} \ 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 2bf343284..e272c071f 100644 --- a/profile/src/main/java/org/openedx/profile/presentation/ProfileRouter.kt +++ b/profile/src/main/java/org/openedx/profile/presentation/ProfileRouter.kt @@ -1,6 +1,7 @@ package org.openedx.profile.presentation import androidx.fragment.app.FragmentManager +import org.openedx.core.presentation.settings.VideoQualityType import org.openedx.profile.domain.model.Account interface ProfileRouter { @@ -9,7 +10,7 @@ interface ProfileRouter { fun navigateToVideoSettings(fm: FragmentManager) - fun navigateToVideoQuality(fm: FragmentManager) + fun navigateToVideoQuality(fm: FragmentManager, videoQualityType: VideoQualityType) fun navigateToDeleteAccount(fm: FragmentManager) diff --git a/profile/src/main/java/org/openedx/profile/presentation/delete/DeleteProfileViewModel.kt b/profile/src/main/java/org/openedx/profile/presentation/delete/DeleteProfileViewModel.kt index 40e03d53f..1f39bd03f 100644 --- a/profile/src/main/java/org/openedx/profile/presentation/delete/DeleteProfileViewModel.kt +++ b/profile/src/main/java/org/openedx/profile/presentation/delete/DeleteProfileViewModel.kt @@ -59,4 +59,4 @@ class DeleteProfileViewModel( } } } -} \ No newline at end of file +} diff --git a/profile/src/main/java/org/openedx/profile/presentation/edit/EditProfileViewModel.kt b/profile/src/main/java/org/openedx/profile/presentation/edit/EditProfileViewModel.kt index 1aec603a2..b9f4c0991 100644 --- a/profile/src/main/java/org/openedx/profile/presentation/edit/EditProfileViewModel.kt +++ b/profile/src/main/java/org/openedx/profile/presentation/edit/EditProfileViewModel.kt @@ -135,4 +135,4 @@ class EditProfileViewModel( analytics.profileDeleteAccountClickedEvent() } -} \ 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 0b0eafd8a..2ed5818ad 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 @@ -136,7 +136,7 @@ class ProfileViewModel( fun logout() { viewModelScope.launch { try { - workerController.cancelWork() + workerController.removeModels() withContext(dispatcher) { interactor.logout() } diff --git a/profile/src/main/java/org/openedx/profile/presentation/settings/video/VideoQualityViewModel.kt b/profile/src/main/java/org/openedx/profile/presentation/settings/video/VideoQualityViewModel.kt deleted file mode 100644 index 06c9bd6e6..000000000 --- a/profile/src/main/java/org/openedx/profile/presentation/settings/video/VideoQualityViewModel.kt +++ /dev/null @@ -1,37 +0,0 @@ -package org.openedx.profile.presentation.settings.video - -import androidx.lifecycle.LiveData -import androidx.lifecycle.MutableLiveData -import androidx.lifecycle.viewModelScope -import org.openedx.core.BaseViewModel -import org.openedx.core.data.storage.CorePreferences -import org.openedx.core.domain.model.VideoQuality -import org.openedx.profile.system.notifier.ProfileNotifier -import org.openedx.profile.system.notifier.VideoQualityChanged -import kotlinx.coroutines.launch - -class VideoQualityViewModel( - private val preferencesManager: CorePreferences, - private val notifier: ProfileNotifier -) : BaseViewModel() { - - private val _videoQuality = MutableLiveData() - val videoQuality: LiveData - get() = _videoQuality - - val currentVideoQuality = preferencesManager.videoSettings.videoQuality - - init { - _videoQuality.value = preferencesManager.videoSettings.videoQuality - } - - fun setVideoDownloadQuality(quality: VideoQuality) { - val currentSettings = preferencesManager.videoSettings - preferencesManager.videoSettings = currentSettings.copy(videoQuality = quality) - _videoQuality.value = preferencesManager.videoSettings.videoQuality - viewModelScope.launch { - notifier.send(VideoQualityChanged()) - } - } - -} \ No newline at end of file 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 42ecf16f6..1fba67564 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 @@ -48,6 +48,8 @@ import androidx.compose.ui.unit.dp import androidx.fragment.app.Fragment import org.koin.android.ext.android.inject import org.koin.androidx.viewmodel.ext.android.viewModel +import org.openedx.core.presentation.settings.VideoQualityType +import org.openedx.core.ui.BackBtn import org.openedx.core.domain.model.VideoSettings import org.openedx.core.ui.Toolbar import org.openedx.core.ui.WindowSize @@ -60,8 +62,8 @@ 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.R import org.openedx.profile.presentation.ProfileRouter -import org.openedx.profile.R as profileR class VideoSettingsFragment : Fragment() { @@ -94,9 +96,14 @@ class VideoSettingsFragment : Fragment() { wifiDownloadChanged = { viewModel.setWifiDownloadOnly(it) }, + videoStreamingQualityClick = { + router.navigateToVideoQuality( + requireActivity().supportFragmentManager, VideoQualityType.Streaming + ) + }, videoDownloadQualityClick = { router.navigateToVideoQuality( - requireActivity().supportFragmentManager + requireActivity().supportFragmentManager, VideoQualityType.Download ) } ) @@ -112,6 +119,7 @@ private fun VideoSettingsScreen( windowSize: WindowSize, videoSettings: VideoSettings, wifiDownloadChanged: (Boolean) -> Unit, + videoStreamingQualityClick: () -> Unit, videoDownloadQualityClick: () -> Unit, onBackClick: () -> Unit, ) { @@ -188,14 +196,14 @@ private fun VideoSettingsScreen( Column(Modifier.weight(1f)) { Text( modifier = Modifier.testTag("txt_wifi_only_label"), - text = stringResource(id = profileR.string.profile_wifi_only_download), + text = stringResource(id = R.string.profile_wifi_only_download), color = MaterialTheme.appColors.textPrimary, style = MaterialTheme.appTypography.titleMedium ) Spacer(Modifier.height(4.dp)) Text( modifier = Modifier.testTag("txt_wifi_only_description"), - text = stringResource(id = profileR.string.profile_only_download_when_wifi_turned_on), + text = stringResource(id = R.string.profile_only_download_when_wifi_turned_on), color = MaterialTheme.appColors.textSecondary, style = MaterialTheme.appTypography.labelMedium ) @@ -214,6 +222,36 @@ private fun VideoSettingsScreen( ) } Divider() + Row( + Modifier + .fillMaxWidth() + .height(92.dp) + .clickable { + videoStreamingQualityClick() + }, + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Column(Modifier.weight(1f)) { + Text( + text = stringResource(id = org.openedx.core.R.string.core_video_streaming_quality), + color = MaterialTheme.appColors.textPrimary, + style = MaterialTheme.appTypography.titleMedium + ) + Spacer(Modifier.height(4.dp)) + Text( + text = stringResource(id = videoSettings.videoStreamingQuality.titleResId), + color = MaterialTheme.appColors.textSecondary, + style = MaterialTheme.appTypography.labelMedium + ) + } + Icon( + imageVector = Icons.Filled.ChevronRight, + tint = MaterialTheme.appColors.onSurface, + contentDescription = "Expandable Arrow" + ) + } + Divider() Row( Modifier .testTag("btn_video_quality") @@ -227,15 +265,13 @@ private fun VideoSettingsScreen( ) { Column(Modifier.weight(1f)) { Text( - modifier = Modifier.testTag("txt_video_quality_label"), - text = stringResource(id = profileR.string.profile_video_streaming_quality), + text = stringResource(id = org.openedx.core.R.string.core_video_download_quality), color = MaterialTheme.appColors.textPrimary, style = MaterialTheme.appTypography.titleMedium ) Spacer(Modifier.height(4.dp)) Text( - modifier = Modifier.testTag("txt_video_quality_description"), - text = stringResource(id = videoSettings.videoQuality.titleResId), + text = stringResource(id = videoSettings.videoDownloadQuality.titleResId), color = MaterialTheme.appColors.textSecondary, style = MaterialTheme.appTypography.labelMedium ) @@ -261,9 +297,11 @@ private fun VideoSettingsScreenPreview() { VideoSettingsScreen( windowSize = WindowSize(WindowType.Compact, WindowType.Compact), wifiDownloadChanged = {}, + videoStreamingQualityClick = {}, videoDownloadQualityClick = {}, onBackClick = {}, videoSettings = VideoSettings.default ) } } + diff --git a/profile/src/main/java/org/openedx/profile/presentation/settings/video/VideoSettingsViewModel.kt b/profile/src/main/java/org/openedx/profile/presentation/settings/video/VideoSettingsViewModel.kt index 743986b09..f5ca673c6 100644 --- a/profile/src/main/java/org/openedx/profile/presentation/settings/video/VideoSettingsViewModel.kt +++ b/profile/src/main/java/org/openedx/profile/presentation/settings/video/VideoSettingsViewModel.kt @@ -4,17 +4,17 @@ import androidx.lifecycle.LifecycleOwner import androidx.lifecycle.LiveData import androidx.lifecycle.MutableLiveData import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.launch import org.openedx.core.BaseViewModel import org.openedx.core.data.storage.CorePreferences import org.openedx.core.domain.model.VideoSettings -import org.openedx.profile.system.notifier.ProfileNotifier -import org.openedx.profile.system.notifier.VideoQualityChanged -import kotlinx.coroutines.flow.collectLatest -import kotlinx.coroutines.launch +import org.openedx.core.system.notifier.VideoNotifier +import org.openedx.core.system.notifier.VideoQualityChanged class VideoSettingsViewModel( private val preferencesManager: CorePreferences, - private val notifier: ProfileNotifier + private val notifier: VideoNotifier ) : BaseViewModel() { private val _videoSettings = MutableLiveData() @@ -45,4 +45,4 @@ class VideoSettingsViewModel( _videoSettings.value = preferencesManager.videoSettings } -} \ No newline at end of file +} diff --git a/profile/src/main/java/org/openedx/profile/system/notifier/AccountDeactivated.kt b/profile/src/main/java/org/openedx/profile/system/notifier/AccountDeactivated.kt index 71f1d8d92..ff09cbf72 100644 --- a/profile/src/main/java/org/openedx/profile/system/notifier/AccountDeactivated.kt +++ b/profile/src/main/java/org/openedx/profile/system/notifier/AccountDeactivated.kt @@ -1,3 +1,3 @@ package org.openedx.profile.system.notifier -class AccountDeactivated : ProfileEvent \ No newline at end of file +class AccountDeactivated : ProfileEvent diff --git a/profile/src/main/java/org/openedx/profile/system/notifier/AccountUpdated.kt b/profile/src/main/java/org/openedx/profile/system/notifier/AccountUpdated.kt index 26e940a3a..2870235f2 100644 --- a/profile/src/main/java/org/openedx/profile/system/notifier/AccountUpdated.kt +++ b/profile/src/main/java/org/openedx/profile/system/notifier/AccountUpdated.kt @@ -1,3 +1,3 @@ package org.openedx.profile.system.notifier -class AccountUpdated : ProfileEvent \ No newline at end of file +class AccountUpdated : ProfileEvent diff --git a/profile/src/main/java/org/openedx/profile/system/notifier/ProfileEvent.kt b/profile/src/main/java/org/openedx/profile/system/notifier/ProfileEvent.kt index e95caacdb..dbe877081 100644 --- a/profile/src/main/java/org/openedx/profile/system/notifier/ProfileEvent.kt +++ b/profile/src/main/java/org/openedx/profile/system/notifier/ProfileEvent.kt @@ -1,4 +1,3 @@ package org.openedx.profile.system.notifier -interface ProfileEvent { -} \ No newline at end of file +interface ProfileEvent diff --git a/profile/src/main/java/org/openedx/profile/system/notifier/ProfileNotifier.kt b/profile/src/main/java/org/openedx/profile/system/notifier/ProfileNotifier.kt index 4d826c5d9..c51d82340 100644 --- a/profile/src/main/java/org/openedx/profile/system/notifier/ProfileNotifier.kt +++ b/profile/src/main/java/org/openedx/profile/system/notifier/ProfileNotifier.kt @@ -3,6 +3,7 @@ package org.openedx.profile.system.notifier import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.asSharedFlow +import org.openedx.core.system.notifier.VideoQualityChanged class ProfileNotifier { @@ -11,7 +12,6 @@ class ProfileNotifier { val notifier: Flow = channel.asSharedFlow() suspend fun send(event: AccountUpdated) = channel.emit(event) - suspend fun send(event: VideoQualityChanged) = channel.emit(event) suspend fun send(event: AccountDeactivated) = channel.emit(event) -} \ No newline at end of file +} diff --git a/profile/src/main/java/org/openedx/profile/system/notifier/VideoQualityChanged.kt b/profile/src/main/java/org/openedx/profile/system/notifier/VideoQualityChanged.kt deleted file mode 100644 index 4f372db31..000000000 --- a/profile/src/main/java/org/openedx/profile/system/notifier/VideoQualityChanged.kt +++ /dev/null @@ -1,3 +0,0 @@ -package org.openedx.profile.system.notifier - -class VideoQualityChanged : ProfileEvent \ 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 1cbb0a60a..7d617f734 100644 --- a/profile/src/main/res/values-uk/strings.xml +++ b/profile/src/main/res/values-uk/strings.xml @@ -30,7 +30,6 @@ Налаштування відео Завантаження тільки через Wi-Fi Завантажуйте вміст лише тоді, коли ввімкнено wi-fi - Якість транслювання відео Видалити акаунт Ви впевнені, що бажаєте видалити свій акаунт? @@ -44,4 +43,4 @@ Продовжити редагування Зміни, які ви внесли, можуть не бути збереженими. - \ No newline at end of file + diff --git a/profile/src/main/res/values/strings.xml b/profile/src/main/res/values/strings.xml index 03f82fa8a..ac4b4cbf3 100644 --- a/profile/src/main/res/values/strings.xml +++ b/profile/src/main/res/values/strings.xml @@ -38,10 +38,9 @@ Video settings Wi-fi only download Only download content when wi-fi is turned on - Video streaming quality Leave profile? Leave Keep editing Changes you have made may not be saved. - \ No newline at end of file + diff --git a/profile/src/test/java/org/openedx/profile/presentation/edit/EditProfileViewModelTest.kt b/profile/src/test/java/org/openedx/profile/presentation/edit/EditProfileViewModelTest.kt index ecfb1fadf..3a6dd29bd 100644 --- a/profile/src/test/java/org/openedx/profile/presentation/edit/EditProfileViewModelTest.kt +++ b/profile/src/test/java/org/openedx/profile/presentation/edit/EditProfileViewModelTest.kt @@ -185,4 +185,4 @@ class EditProfileViewModelTest { assert(viewModel.selectedImageUri.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 45d346671..7112eac51 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 @@ -222,7 +222,7 @@ class ProfileViewModelTest { appUpgradeNotifier ) coEvery { interactor.logout() } throws UnknownHostException() - coEvery { workerController.cancelWork() } returns Unit + coEvery { workerController.removeModels() } returns Unit every { analytics.logoutEvent(false) } returns Unit every { cookieManager.clearWebViewCookie() } returns Unit viewModel.logout() @@ -252,7 +252,7 @@ class ProfileViewModelTest { appUpgradeNotifier ) coEvery { interactor.logout() } throws Exception() - coEvery { workerController.cancelWork() } returns Unit + coEvery { workerController.removeModels() } returns Unit every { analytics.logoutEvent(false) } returns Unit every { cookieManager.clearWebViewCookie() } returns Unit viewModel.logout() @@ -287,7 +287,7 @@ class ProfileViewModelTest { coEvery { interactor.getAccount() } returns mockk() every { analytics.logoutEvent(false) } returns Unit coEvery { interactor.logout() } returns Unit - coEvery { workerController.cancelWork() } returns Unit + coEvery { workerController.removeModels() } returns Unit every { cookieManager.clearWebViewCookie() } returns Unit viewModel.logout() advanceUntilIdle() From fc5c6487ed45dee3357420a5574f3ca93cf25cb8 Mon Sep 17 00:00:00 2001 From: Igor Date: Mon, 26 Feb 2024 11:36:47 +0100 Subject: [PATCH 10/39] fix: progress bar style changed (#235) --- .../java/org/openedx/core/ui/theme/AppColors.kt | 3 ++- .../java/org/openedx/core/ui/theme/Theme.kt | 2 ++ .../openedx/org/openedx/core/ui/theme/Colors.kt | 6 ++++-- .../openedx/course/presentation/ui/CourseUI.kt | 17 ++++++++++++----- .../container/CourseUnitContainerFragment.kt | 1 + .../layout/fragment_course_unit_container.xml | 4 ++-- 6 files changed, 23 insertions(+), 10 deletions(-) diff --git a/core/src/main/java/org/openedx/core/ui/theme/AppColors.kt b/core/src/main/java/org/openedx/core/ui/theme/AppColors.kt index 23d82a4c6..3a540d9fa 100644 --- a/core/src/main/java/org/openedx/core/ui/theme/AppColors.kt +++ b/core/src/main/java/org/openedx/core/ui/theme/AppColors.kt @@ -46,6 +46,7 @@ data class AppColors( val authFacebookButtonBackground: Color, val authMicrosoftButtonBackground: Color, + val componentHorizontalProgressCompletedAndSelected: Color, val componentHorizontalProgressCompleted: Color, val componentHorizontalProgressSelected: Color, val componentHorizontalProgressDefault: Color, @@ -63,4 +64,4 @@ data class AppColors( val onSurface: Color get() = material.onSurface val onError: Color get() = material.onError val isLight: Boolean get() = material.isLight -} \ No newline at end of file +} 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 fbe6d80b5..e5e7a00d3 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 @@ -66,6 +66,7 @@ private val DarkColorPalette = AppColors( authFacebookButtonBackground = dark_auth_facebook_button_background, authMicrosoftButtonBackground = dark_auth_microsoft_button_background, + componentHorizontalProgressCompletedAndSelected = dark_component_horizontal_progress_completed_and_selected, componentHorizontalProgressCompleted = dark_component_horizontal_progress_completed, componentHorizontalProgressSelected = dark_component_horizontal_progress_selected, componentHorizontalProgressDefault = dark_component_horizontal_progress_default, @@ -127,6 +128,7 @@ private val LightColorPalette = AppColors( authFacebookButtonBackground = light_auth_facebook_button_background, authMicrosoftButtonBackground = light_auth_microsoft_button_background, + componentHorizontalProgressCompletedAndSelected = light_component_horizontal_progress_completed_and_selected, componentHorizontalProgressCompleted = light_component_horizontal_progress_completed, componentHorizontalProgressSelected = light_component_horizontal_progress_selected, componentHorizontalProgressDefault = light_component_horizontal_progress_default, diff --git a/core/src/openedx/org/openedx/core/ui/theme/Colors.kt b/core/src/openedx/org/openedx/core/ui/theme/Colors.kt index 856e7c7d7..29f09b21b 100644 --- a/core/src/openedx/org/openedx/core/ui/theme/Colors.kt +++ b/core/src/openedx/org/openedx/core/ui/theme/Colors.kt @@ -45,7 +45,8 @@ val light_dates_section_bar_next_week = light_text_field_border val light_dates_section_bar_upcoming = Color(0xFFCCD4E0) val light_auth_facebook_button_background = Color(0xFF0866FF) val light_auth_microsoft_button_background = Color(0xFA000000) -val light_component_horizontal_progress_completed = Color(0xFF2EA171) +val light_component_horizontal_progress_completed_and_selected = Color(0xFF30a171) +val light_component_horizontal_progress_completed = Color(0xFFbbe6d7) val light_component_horizontal_progress_selected = Color(0xFFF0CB00) val light_component_horizontal_progress_default = Color(0xFFD6D3D1) @@ -93,6 +94,7 @@ val dark_dates_section_bar_next_week = dark_text_field_border val dark_dates_section_bar_upcoming = Color(0xFFCCD4E0) val dark_auth_facebook_button_background = Color(0xFF0866FF) val dark_auth_microsoft_button_background = Color(0xFA000000) -val dark_component_horizontal_progress_completed = Color(0xFF2EA171) +val dark_component_horizontal_progress_completed_and_selected = Color(0xFF30a171) +val dark_component_horizontal_progress_completed = Color(0xFFbbe6d7) val dark_component_horizontal_progress_selected = Color(0xFFF0CB00) val dark_component_horizontal_progress_default = Color(0xFFD6D3D1) diff --git a/course/src/main/java/org/openedx/course/presentation/ui/CourseUI.kt b/course/src/main/java/org/openedx/course/presentation/ui/CourseUI.kt index 63e11c7f5..051466d25 100644 --- a/course/src/main/java/org/openedx/course/presentation/ui/CourseUI.kt +++ b/course/src/main/java/org/openedx/course/presentation/ui/CourseUI.kt @@ -540,6 +540,7 @@ fun HorizontalPageIndicator( modifier: Modifier = Modifier, blocks: List, selectedPage: Int = 0, + completedAndSelectedColor: Color = Color.Green, completedColor: Color = Color.Green, selectedColor: Color = Color.White, defaultColor: Color = Color.Gray @@ -550,17 +551,23 @@ fun HorizontalPageIndicator( ) { blocks.forEachIndexed { index, block -> val backgroundColor = when { - index == selectedPage -> selectedColor + block.isCompleted() && index == selectedPage -> completedAndSelectedColor block.isCompleted() -> completedColor + index == selectedPage -> selectedColor else -> defaultColor } - Box( + Surface( modifier = Modifier - .background(backgroundColor) - .fillMaxHeight() + .padding(vertical = if (index == selectedPage) 0.dp else 1.dp) .weight(1f) - ) + ) { + Box( + modifier = Modifier + .background(backgroundColor) + .fillMaxHeight() + ) + } } } } diff --git a/course/src/main/java/org/openedx/course/presentation/unit/container/CourseUnitContainerFragment.kt b/course/src/main/java/org/openedx/course/presentation/unit/container/CourseUnitContainerFragment.kt index 9bee43fe6..fb6cd7a2d 100644 --- a/course/src/main/java/org/openedx/course/presentation/unit/container/CourseUnitContainerFragment.kt +++ b/course/src/main/java/org/openedx/course/presentation/unit/container/CourseUnitContainerFragment.kt @@ -133,6 +133,7 @@ class CourseUnitContainerFragment : Fragment(R.layout.fragment_course_unit_conta HorizontalPageIndicator( blocks = descendantsBlocks, selectedPage = index, + completedAndSelectedColor = MaterialTheme.appColors.componentHorizontalProgressCompletedAndSelected, completedColor = MaterialTheme.appColors.componentHorizontalProgressCompleted, selectedColor = MaterialTheme.appColors.componentHorizontalProgressSelected, defaultColor = MaterialTheme.appColors.componentHorizontalProgressDefault diff --git a/course/src/main/res/layout/fragment_course_unit_container.xml b/course/src/main/res/layout/fragment_course_unit_container.xml index 2a5de0a9b..4f6367325 100644 --- a/course/src/main/res/layout/fragment_course_unit_container.xml +++ b/course/src/main/res/layout/fragment_course_unit_container.xml @@ -32,7 +32,7 @@ - \ No newline at end of file + From 92d697fea674ce8a04a872d7feacbec7954366c0 Mon Sep 17 00:00:00 2001 From: Hamza Israr <71447999+HamzaIsrar12@users.noreply.github.com> Date: Mon, 26 Feb 2024 17:22:54 +0500 Subject: [PATCH 11/39] feat: Sync Course Dates to Calendar (#228) Calendar Sync Integration: - Integrated "Sync to Calendar" switch on Course Dates tab. - Dynamically show/hide switch based on Remote config. - Added user permission prompt for calendar access. - Added confirmation alert for initial course additions. - Included course dates as events in the local app calendar. - Added AlertDialog Loader for event creation/update. - Implemented Calendar preference for streamlined alert management. - Added user prompt for updating or removing the calendar. - Added a prompt for out-of-sync calendar situations. - Update calendar events on 'Shift Due Dates' CTA. Remote Config for Calendar Feature: - Retrieve remote config from LMS within the enrollments API. Store the configuration in CorePreferences, ensuring it is updated with each enrollments API call. - The CalendarSync Config now manages specific values pertinent to the Calendar Sync integration on both the Course Home and Dates tab. Fixes: LEARNER-9801 --- .../app/data/storage/PreferencesManager.kt | 24 +- .../main/java/org/openedx/app/di/AppModule.kt | 17 +- .../java/org/openedx/app/di/ScreenModule.kt | 13 +- .../org/openedx/core/data/model/AppConfig.kt | 15 + .../core/data/model/CalendarSyncConfig.kt | 29 ++ .../core/data/model/CourseEnrollments.kt | 65 ++- .../core/data/storage/CorePreferences.kt | 2 + .../openedx/core/domain/model/AppConfig.kt | 14 + .../org/openedx/core/extension/StringExt.kt | 4 + .../core/system/notifier/CalendarSyncEvent.kt | 13 + .../core/system/notifier/CourseNotifier.kt | 4 +- .../java/org/openedx/core/utils/TimeUtils.kt | 6 + course/src/main/AndroidManifest.xml | 5 + .../course/data/storage/CoursePreferences.kt | 6 + .../calendarsync/CalendarManager.kt | 398 ++++++++++++++++++ .../calendarsync/CalendarSyncDialog.kt | 228 ++++++++++ .../calendarsync/CalendarSyncDialogType.kt | 45 ++ .../calendarsync/CalendarSyncUIState.kt | 14 + .../calendarsync/DialogProperties.kt | 10 + .../container/CourseContainerFragment.kt | 89 +++- .../container/CourseContainerViewModel.kt | 175 +++++++- .../presentation/dates/CourseDatesFragment.kt | 253 +++++++---- .../dates/CourseDatesViewModel.kt | 97 ++++- .../outline/CourseOutlineViewModel.kt | 41 +- .../res/drawable/course_ic_calenday_sync.xml | 12 + .../res/layout/fragment_course_container.xml | 11 +- course/src/main/res/values/strings.xml | 35 ++ .../container/CourseContainerViewModelTest.kt | 98 ++++- .../dates/CourseDatesViewModelTest.kt | 81 +++- .../outline/CourseOutlineViewModelTest.kt | 64 ++- .../data/repository/DashboardRepository.kt | 7 +- 31 files changed, 1731 insertions(+), 144 deletions(-) create mode 100644 core/src/main/java/org/openedx/core/data/model/AppConfig.kt create mode 100644 core/src/main/java/org/openedx/core/data/model/CalendarSyncConfig.kt create mode 100644 core/src/main/java/org/openedx/core/domain/model/AppConfig.kt create mode 100644 core/src/main/java/org/openedx/core/system/notifier/CalendarSyncEvent.kt create mode 100644 course/src/main/AndroidManifest.xml create mode 100644 course/src/main/java/org/openedx/course/data/storage/CoursePreferences.kt create mode 100644 course/src/main/java/org/openedx/course/presentation/calendarsync/CalendarManager.kt create mode 100644 course/src/main/java/org/openedx/course/presentation/calendarsync/CalendarSyncDialog.kt create mode 100644 course/src/main/java/org/openedx/course/presentation/calendarsync/CalendarSyncDialogType.kt create mode 100644 course/src/main/java/org/openedx/course/presentation/calendarsync/CalendarSyncUIState.kt create mode 100644 course/src/main/java/org/openedx/course/presentation/calendarsync/DialogProperties.kt create mode 100644 course/src/main/res/drawable/course_ic_calenday_sync.xml 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 eeeccd39c..603876d54 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 @@ -6,14 +6,17 @@ import org.openedx.app.BuildConfig import org.openedx.core.data.model.User import org.openedx.core.data.storage.CorePreferences import org.openedx.core.data.storage.InAppReviewPreferences +import org.openedx.core.domain.model.AppConfig import org.openedx.core.domain.model.VideoQuality import org.openedx.core.domain.model.VideoSettings +import org.openedx.core.extension.replaceSpace +import org.openedx.course.data.storage.CoursePreferences 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, - WhatsNewPreferences, InAppReviewPreferences { + WhatsNewPreferences, InAppReviewPreferences, CoursePreferences { private val sharedPreferences = context.getSharedPreferences(BuildConfig.APPLICATION_ID, Context.MODE_PRIVATE) @@ -113,6 +116,16 @@ class PreferencesManager(context: Context) : CorePreferences, ProfilePreferences ) } + override var appConfig: AppConfig + set(value) { + val appConfigJson = Gson().toJson(value) + saveString(APP_CONFIG, appConfigJson) + } + get() { + val appConfigString = getString(APP_CONFIG) + return Gson().fromJson(appConfigString, AppConfig::class.java) + } + override var lastWhatsNewVersion: String set(value) { saveString(LAST_WHATS_NEW_VERSION, value) @@ -133,13 +146,19 @@ class PreferencesManager(context: Context) : CorePreferences, ProfilePreferences ?: InAppReviewPreferences.VersionName.default } - override var wasPositiveRated: Boolean set(value) { saveBoolean(APP_WAS_POSITIVE_RATED, value) } get() = getBoolean(APP_WAS_POSITIVE_RATED) + override fun setCalendarSyncEventsDialogShown(courseName: String) { + saveBoolean(courseName.replaceSpace("_"), true) + } + + override fun isCalendarSyncEventsDialogShown(courseName: String): Boolean = + getBoolean(courseName.replaceSpace("_")) + companion object { private const val ACCESS_TOKEN = "access_token" private const val REFRESH_TOKEN = "refresh_token" @@ -152,5 +171,6 @@ class PreferencesManager(context: Context) : CorePreferences, ProfilePreferences private const val VIDEO_SETTINGS_WIFI_DOWNLOAD_ONLY = "video_settings_wifi_download_only" private const val VIDEO_SETTINGS_STREAMING_QUALITY = "video_settings_streaming_quality" private const val VIDEO_SETTINGS_DOWNLOAD_QUALITY = "video_settings_download_quality" + private const val APP_CONFIG = "app_config" } } 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 dba5727d5..403f50d0c 100644 --- a/app/src/main/java/org/openedx/app/di/AppModule.kt +++ b/app/src/main/java/org/openedx/app/di/AppModule.kt @@ -25,6 +25,7 @@ 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.model.CourseEnrollments import org.openedx.core.data.storage.CorePreferences import org.openedx.core.data.storage.InAppReviewPreferences import org.openedx.core.interfaces.EnrollInCourseInteractor @@ -40,14 +41,16 @@ 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.core.system.notifier.DownloadNotifier import org.openedx.core.system.notifier.VideoNotifier +import org.openedx.course.data.storage.CoursePreferences +import org.openedx.course.domain.interactor.CourseInteractor import org.openedx.course.presentation.CourseAnalytics import org.openedx.course.presentation.CourseRouter -import org.openedx.dashboard.presentation.dashboard.DashboardAnalytics +import org.openedx.course.presentation.calendarsync.CalendarManager +import org.openedx.dashboard.notifier.DashboardNotifier import org.openedx.dashboard.presentation.DashboardRouter +import org.openedx.dashboard.presentation.dashboard.DashboardAnalytics import org.openedx.discovery.presentation.DiscoveryAnalytics import org.openedx.discovery.presentation.DiscoveryRouter import org.openedx.discussion.presentation.DiscussionAnalytics @@ -69,12 +72,18 @@ val appModule = module { single { get() } single { get() } single { get() } + single { get() } single { ResourceManager(get()) } single { AppCookieManager(get(), get()) } single { ReviewManagerFactory.create(get()) } + single { CalendarManager(get(), get(), get()) } - single { GsonBuilder().create() } + single { + GsonBuilder() + .registerTypeAdapter(CourseEnrollments::class.java, CourseEnrollments.Deserializer()) + .create() + } single { AppNotifier() } single { CourseNotifier() } 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 8391a0e03..a69c5f01e 100644 --- a/app/src/main/java/org/openedx/app/di/ScreenModule.kt +++ b/app/src/main/java/org/openedx/app/di/ScreenModule.kt @@ -161,7 +161,10 @@ val screenModule = module { get(), get(), get(), - get() + get(), + get(), + get(), + get(), ) } viewModel { (courseId: String) -> @@ -229,13 +232,17 @@ val screenModule = module { get() ) } - viewModel { (courseId: String, isSelfPaced: Boolean) -> + viewModel { (courseId: String, courseName: String, isSelfPaced: Boolean) -> CourseDatesViewModel( courseId, + courseName, isSelfPaced, get(), get(), - get() + get(), + get(), + get(), + get(), ) } viewModel { (courseId: String, handoutsType: String) -> diff --git a/core/src/main/java/org/openedx/core/data/model/AppConfig.kt b/core/src/main/java/org/openedx/core/data/model/AppConfig.kt new file mode 100644 index 000000000..4fcbe3d89 --- /dev/null +++ b/core/src/main/java/org/openedx/core/data/model/AppConfig.kt @@ -0,0 +1,15 @@ +package org.openedx.core.data.model + +import com.google.gson.annotations.SerializedName +import org.openedx.core.domain.model.AppConfig as DomainAppConfig + +data class AppConfig( + @SerializedName("course_dates_calendar_sync") + val calendarSyncConfig: CalendarSyncConfig = CalendarSyncConfig(), +) { + fun mapToDomain(): DomainAppConfig { + return DomainAppConfig( + courseDatesCalendarSync = calendarSyncConfig.mapToDomain(), + ) + } +} diff --git a/core/src/main/java/org/openedx/core/data/model/CalendarSyncConfig.kt b/core/src/main/java/org/openedx/core/data/model/CalendarSyncConfig.kt new file mode 100644 index 000000000..bfd09b3d3 --- /dev/null +++ b/core/src/main/java/org/openedx/core/data/model/CalendarSyncConfig.kt @@ -0,0 +1,29 @@ +package org.openedx.core.data.model + +import com.google.gson.annotations.SerializedName +import org.openedx.core.domain.model.CourseDatesCalendarSync + +data class CalendarSyncConfig( + @SerializedName("android") + val platformConfig: CalendarSyncPlatform = CalendarSyncPlatform(), +) { + fun mapToDomain(): CourseDatesCalendarSync { + return CourseDatesCalendarSync( + isEnabled = platformConfig.enabled, + isSelfPacedEnabled = platformConfig.selfPacedEnabled, + isInstructorPacedEnabled = platformConfig.instructorPacedEnabled, + isDeepLinkEnabled = platformConfig.deepLinksEnabled, + ) + } +} + +data class CalendarSyncPlatform( + @SerializedName("enabled") + val enabled: Boolean = false, + @SerializedName("self_paced_enabled") + val selfPacedEnabled: Boolean = false, + @SerializedName("instructor_paced_enabled") + val instructorPacedEnabled: Boolean = false, + @SerializedName("deep_links_enabled") + val deepLinksEnabled: Boolean = 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 index 1c10cfa92..89ecdcab4 100644 --- a/core/src/main/java/org/openedx/core/data/model/CourseEnrollments.kt +++ b/core/src/main/java/org/openedx/core/data/model/CourseEnrollments.kt @@ -1,8 +1,69 @@ package org.openedx.core.data.model +import com.google.gson.Gson +import com.google.gson.JsonDeserializationContext +import com.google.gson.JsonDeserializer +import com.google.gson.JsonElement +import com.google.gson.JsonObject import com.google.gson.annotations.SerializedName +import java.lang.reflect.Type data class CourseEnrollments( @SerializedName("enrollments") - val enrollments: DashboardCourseList -) + val enrollments: DashboardCourseList, + + @SerializedName("config") + val configs: AppConfig, +) { + class Deserializer : JsonDeserializer { + override fun deserialize( + json: JsonElement?, + typeOfT: Type?, + context: JsonDeserializationContext? + ): CourseEnrollments { + val enrollments = deserializeEnrollments(json) + val appConfig = deserializeAppConfig(json) + + return CourseEnrollments(enrollments, appConfig) + } + + private fun deserializeEnrollments(json: JsonElement?): DashboardCourseList { + return try { + Gson().fromJson( + (json as JsonObject).get("enrollments"), + DashboardCourseList::class.java + ) + } catch (ex: Exception) { + DashboardCourseList( + next = null, + previous = null, + count = 0, + numPages = 0, + currentPage = 0, + results = listOf() + ) + } + } + + /** + * To remove dependency on the backend, all the data related to Remote Config + * will be received under the `configs` key. The `config` is the key under + * 'configs` which defines the data that is related to the configuration of the + * app. + */ + private fun deserializeAppConfig(json: JsonElement?): AppConfig { + return try { + val config = (json as JsonObject) + .getAsJsonObject("configs") + .getAsJsonPrimitive("config") + + Gson().fromJson( + config.asString, + AppConfig::class.java + ) + } catch (ex: Exception) { + AppConfig() + } + } + } +} 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 11f21c661..48999ab4e 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,6 +1,7 @@ package org.openedx.core.data.storage import org.openedx.core.data.model.User +import org.openedx.core.domain.model.AppConfig import org.openedx.core.domain.model.VideoSettings interface CorePreferences { @@ -9,6 +10,7 @@ interface CorePreferences { var accessTokenExpiresAt: Long var user: User? var videoSettings: VideoSettings + var appConfig: AppConfig fun clear() } diff --git a/core/src/main/java/org/openedx/core/domain/model/AppConfig.kt b/core/src/main/java/org/openedx/core/domain/model/AppConfig.kt new file mode 100644 index 000000000..596fd0619 --- /dev/null +++ b/core/src/main/java/org/openedx/core/domain/model/AppConfig.kt @@ -0,0 +1,14 @@ +package org.openedx.core.domain.model + +import java.io.Serializable + +data class AppConfig( + val courseDatesCalendarSync: CourseDatesCalendarSync, +) : Serializable + +data class CourseDatesCalendarSync( + val isEnabled: Boolean, + val isSelfPacedEnabled: Boolean, + val isInstructorPacedEnabled: Boolean, + val isDeepLinkEnabled: Boolean, +) : Serializable diff --git a/core/src/main/java/org/openedx/core/extension/StringExt.kt b/core/src/main/java/org/openedx/core/extension/StringExt.kt index 58a8eef26..343398782 100644 --- a/core/src/main/java/org/openedx/core/extension/StringExt.kt +++ b/core/src/main/java/org/openedx/core/extension/StringExt.kt @@ -33,3 +33,7 @@ fun String.replaceLinkTags(isDarkTheme: Boolean): String { fun String.replaceSpace(target: String = ""): String = this.replace(" ", target) fun String.tagId(): String = this.replaceSpace("_").lowercase(Locale.getDefault()) + +fun String.takeIfNotEmpty(): String? { + return if (this.isEmpty().not()) this else null +} diff --git a/core/src/main/java/org/openedx/core/system/notifier/CalendarSyncEvent.kt b/core/src/main/java/org/openedx/core/system/notifier/CalendarSyncEvent.kt new file mode 100644 index 000000000..f33c8d921 --- /dev/null +++ b/core/src/main/java/org/openedx/core/system/notifier/CalendarSyncEvent.kt @@ -0,0 +1,13 @@ +package org.openedx.core.system.notifier + +import org.openedx.core.domain.model.CourseDateBlock + +sealed class CalendarSyncEvent : CourseEvent { + class CreateCalendarSyncEvent( + val courseDates: List, + val dialogType: String, + val checkOutOfSync: Boolean, + ) : CalendarSyncEvent() + + class CheckCalendarSyncEvent(val isSynced: Boolean) : CalendarSyncEvent() +} 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 3b5c48099..ddd338540 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 @@ -16,5 +16,5 @@ class CourseNotifier { 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 + suspend fun send(event: CalendarSyncEvent) = channel.emit(event) +} 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 e85397491..d77a1ab5e 100644 --- a/core/src/main/java/org/openedx/core/utils/TimeUtils.kt +++ b/core/src/main/java/org/openedx/core/utils/TimeUtils.kt @@ -305,3 +305,9 @@ fun Date.isTimeLessThan24Hours(): Boolean { val timeInMillis = (calendar.timeInMillis - TimeUtils.getCurrentTime()).unaryPlus() return timeInMillis < TimeUnit.DAYS.toMillis(1) } + +fun Date.toCalendar(): Calendar { + val calendar = Calendar.getInstance() + calendar.time = this + return calendar +} diff --git a/course/src/main/AndroidManifest.xml b/course/src/main/AndroidManifest.xml new file mode 100644 index 000000000..5c18ebdbf --- /dev/null +++ b/course/src/main/AndroidManifest.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/course/src/main/java/org/openedx/course/data/storage/CoursePreferences.kt b/course/src/main/java/org/openedx/course/data/storage/CoursePreferences.kt new file mode 100644 index 000000000..8190378de --- /dev/null +++ b/course/src/main/java/org/openedx/course/data/storage/CoursePreferences.kt @@ -0,0 +1,6 @@ +package org.openedx.course.data.storage + +interface CoursePreferences { + fun setCalendarSyncEventsDialogShown(courseName: String) + fun isCalendarSyncEventsDialogShown(courseName: String): Boolean +} diff --git a/course/src/main/java/org/openedx/course/presentation/calendarsync/CalendarManager.kt b/course/src/main/java/org/openedx/course/presentation/calendarsync/CalendarManager.kt new file mode 100644 index 000000000..dcae8e0c2 --- /dev/null +++ b/course/src/main/java/org/openedx/course/presentation/calendarsync/CalendarManager.kt @@ -0,0 +1,398 @@ +package org.openedx.course.presentation.calendarsync + +import android.annotation.SuppressLint +import android.content.ContentUris +import android.content.ContentValues +import android.content.Context +import android.content.Intent +import android.content.pm.PackageManager +import android.database.Cursor +import android.net.Uri +import android.provider.CalendarContract +import androidx.core.content.ContextCompat +import org.openedx.core.data.storage.CorePreferences +import org.openedx.core.domain.model.CourseDateBlock +import org.openedx.core.system.ResourceManager +import org.openedx.core.utils.Logger +import org.openedx.core.utils.toCalendar +import org.openedx.course.R +import java.util.Calendar +import java.util.TimeZone +import java.util.concurrent.TimeUnit +import org.openedx.core.R as CoreR + +class CalendarManager( + private val context: Context, + private val corePreferences: CorePreferences, + private val resourceManager: ResourceManager, +) { + private val logger = Logger(TAG) + + val permissions = arrayOf( + android.Manifest.permission.WRITE_CALENDAR, + android.Manifest.permission.READ_CALENDAR + ) + + private val accountName: String + get() = getUserAccountForSync() + + /** + * Check if the app has the calendar READ/WRITE permissions or not + */ + fun hasPermissions(): Boolean = permissions.all { + PackageManager.PERMISSION_GRANTED == ContextCompat.checkSelfPermission(context, it) + } + + /** + * Check if the calendar is already existed in mobile calendar app or not + */ + fun isCalendarExists(calendarTitle: String): Boolean { + if (hasPermissions()) { + return getCalendarId(calendarTitle) != CALENDAR_DOES_NOT_EXIST + } + return false + } + + /** + * Create or update the calendar if it is already existed in mobile calendar app + */ + fun createOrUpdateCalendar( + calendarTitle: String + ): Long { + val calendarId = getCalendarId( + calendarTitle = calendarTitle + ) + + if (calendarId != CALENDAR_DOES_NOT_EXIST) { + deleteCalendar(calendarId = calendarId) + } + + return createCalendar( + calendarTitle = calendarTitle + ) + } + + /** + * Method to create a separate calendar based on course name in mobile calendar app + */ + private fun createCalendar( + calendarTitle: String + ): Long { + val contentValues = ContentValues() + contentValues.put(CalendarContract.Calendars.NAME, calendarTitle) + contentValues.put(CalendarContract.Calendars.CALENDAR_DISPLAY_NAME, calendarTitle) + contentValues.put(CalendarContract.Calendars.ACCOUNT_NAME, accountName) + contentValues.put( + CalendarContract.Calendars.ACCOUNT_TYPE, + CalendarContract.ACCOUNT_TYPE_LOCAL + ) + contentValues.put(CalendarContract.Calendars.OWNER_ACCOUNT, accountName) + contentValues.put( + CalendarContract.Calendars.CALENDAR_ACCESS_LEVEL, + CalendarContract.Calendars.CAL_ACCESS_ROOT + ) + contentValues.put(CalendarContract.Calendars.SYNC_EVENTS, 1) + contentValues.put(CalendarContract.Calendars.VISIBLE, 1) + contentValues.put( + CalendarContract.Calendars.CALENDAR_COLOR, + ContextCompat.getColor(context, org.openedx.core.R.color.primary) + ) + val creationUri: Uri? = asSyncAdapter( + Uri.parse(CalendarContract.Calendars.CONTENT_URI.toString()), + accountName + ) + creationUri?.let { + val calendarData: Uri? = context.contentResolver.insert(creationUri, contentValues) + calendarData?.let { + val id = calendarData.lastPathSegment?.toLong() + logger.d { "Calendar ID $id" } + return id ?: CALENDAR_DOES_NOT_EXIST + } + } + return CALENDAR_DOES_NOT_EXIST + } + + /** + * Method to check if the calendar with the course name exist in the mobile calendar app or not + */ + @SuppressLint("Range") + fun getCalendarId(calendarTitle: String): Long { + var calendarId = CALENDAR_DOES_NOT_EXIST + val projection = arrayOf( + CalendarContract.Calendars._ID, + CalendarContract.Calendars.ACCOUNT_NAME, + CalendarContract.Calendars.NAME + ) + val calendarContentResolver = context.contentResolver + val cursor = calendarContentResolver.query( + CalendarContract.Calendars.CONTENT_URI, projection, + CalendarContract.Calendars.ACCOUNT_NAME + "=? and (" + + CalendarContract.Calendars.NAME + "=? or " + + CalendarContract.Calendars.CALENDAR_DISPLAY_NAME + "=?)", arrayOf( + accountName, calendarTitle, + calendarTitle + ), null + ) + if (cursor?.moveToFirst() == true) { + if (cursor.getString(cursor.getColumnIndex(CalendarContract.Calendars.NAME)) + .equals(calendarTitle) + ) { + calendarId = + cursor.getInt(cursor.getColumnIndex(CalendarContract.Calendars._ID)).toLong() + } + } + cursor?.close() + return calendarId + } + + /** + * Method to add important dates of course as calendar event into calendar of mobile app + */ + fun addEventsIntoCalendar( + calendarId: Long, + courseId: String, + courseName: String, + courseDateBlock: CourseDateBlock + ) { + val date = courseDateBlock.date.toCalendar() + // start time of the event, adjusted 1 hour earlier for a 1-hour duration + val startMillis: Long = date.timeInMillis - TimeUnit.HOURS.toMillis(1) + // end time of the event added to the calendar + val endMillis: Long = date.timeInMillis + + val values = ContentValues().apply { + put(CalendarContract.Events.DTSTART, startMillis) + put(CalendarContract.Events.DTEND, endMillis) + put( + CalendarContract.Events.TITLE, + "${resourceManager.getString(R.string.course_assignment_due_tag)} : $courseName" + ) + put( + CalendarContract.Events.DESCRIPTION, + getEventDescription( + courseId = courseId, + courseDateBlock = courseDateBlock, + isDeeplinkEnabled = corePreferences.appConfig.courseDatesCalendarSync.isDeepLinkEnabled + ) + ) + put(CalendarContract.Events.CALENDAR_ID, calendarId) + put(CalendarContract.Events.EVENT_TIMEZONE, TimeZone.getDefault().id) + } + val uri = context.contentResolver.insert(CalendarContract.Events.CONTENT_URI, values) + uri?.let { addReminderToEvent(uri = it) } + } + + /** + * Method to generate & add deeplink into event description + * + * @return event description with deeplink for assignment block else block title + */ + private fun getEventDescription( + courseId: String, + courseDateBlock: CourseDateBlock, + isDeeplinkEnabled: Boolean + ): String { + val eventDescription = courseDateBlock.title + // The following code for branch and deep links will be enabled after implementation + /* + if (isDeeplinkEnabled && !TextUtils.isEmpty(courseDateBlock.blockId)) { + val metaData = ContentMetadata() + .addCustomMetadata(DeepLink.Keys.SCREEN_NAME, Screen.COURSE_COMPONENT) + .addCustomMetadata(DeepLink.Keys.COURSE_ID, courseId) + .addCustomMetadata(DeepLink.Keys.COMPONENT_ID, courseDateBlock.blockId) + + val branchUniversalObject = BranchUniversalObject() + .setCanonicalIdentifier("${Screen.COURSE_COMPONENT}\n${courseDateBlock.blockId}") + .setTitle(courseDateBlock.title) + .setContentDescription(courseDateBlock.title) + .setContentMetadata(metaData) + + val linkProperties = LinkProperties() + .addControlParameter("\$desktop_url", courseDateBlock.link) + + eventDescription += "\n" + branchUniversalObject.getShortUrl(context, linkProperties) + } + */ + return eventDescription + } + + /** + * Method to add a reminder to the given calendar events + * + * @param uri Calendar event Uri + */ + private fun addReminderToEvent(uri: Uri) { + val eventId: Long? = uri.lastPathSegment?.toLong() + logger.d { "Event ID $eventId" } + + // Adding reminder on the start of event + val eventValues = ContentValues().apply { + put(CalendarContract.Reminders.MINUTES, 0) + put(CalendarContract.Reminders.EVENT_ID, eventId) + put(CalendarContract.Reminders.METHOD, CalendarContract.Reminders.METHOD_ALERT) + } + context.contentResolver.insert(CalendarContract.Reminders.CONTENT_URI, eventValues) + // Adding reminder 24 hours before the event get started + eventValues.apply { + put(CalendarContract.Reminders.MINUTES, TimeUnit.DAYS.toMinutes(1)) + } + context.contentResolver.insert(CalendarContract.Reminders.CONTENT_URI, eventValues) + // Adding reminder 48 hours before the event get started + eventValues.apply { + put(CalendarContract.Reminders.MINUTES, TimeUnit.DAYS.toMinutes(2)) + } + context.contentResolver.insert(CalendarContract.Reminders.CONTENT_URI, eventValues) + } + + /** + * Method to query the events for the given calendar id + * + * @param calendarId calendarId to query the events + * + * @return [Cursor] + * + * */ + private fun getCalendarEvents(calendarId: Long): Cursor? { + val calendarContentResolver = context.contentResolver + val projection = arrayOf( + CalendarContract.Events._ID, + CalendarContract.Events.DTEND, + CalendarContract.Events.DESCRIPTION + ) + val selection = CalendarContract.Events.CALENDAR_ID + "=?" + return calendarContentResolver.query( + CalendarContract.Events.CONTENT_URI, + projection, + selection, + arrayOf(calendarId.toString()), + null + ) + } + + /** + * Method to compare the calendar events with course dates + * @return true if the events are the same as calendar dates otherwise false + */ + @SuppressLint("Range") + private fun compareEvents( + calendarId: Long, + courseDateBlocks: List + ): Boolean { + val cursor = getCalendarEvents(calendarId) ?: return false + + val datesList = ArrayList(courseDateBlocks) + val dueDateColumnIndex = cursor.getColumnIndex(CalendarContract.Events.DTEND) + val descriptionColumnIndex = cursor.getColumnIndex(CalendarContract.Events.DESCRIPTION) + + while (cursor.moveToNext()) { + val dueDateInMillis = cursor.getLong(dueDateColumnIndex) + + val description = cursor.getString(descriptionColumnIndex) + if (description != null) { + val matchedDate = datesList.find { unit -> + description.contains(unit.title, ignoreCase = true) + } + + matchedDate?.let { unit -> + if (unit.date.toCalendar().timeInMillis == dueDateInMillis) { + datesList.remove(unit) + } else { + // If any single value isn't matched, return false + cursor.close() + return false + } + } + } + } + + cursor.close() + return datesList.isEmpty() + } + + /** + * Method to delete the course calendar from the mobile calendar app + */ + fun deleteCalendar(calendarId: Long) { + context.contentResolver.delete( + Uri.parse("content://com.android.calendar/calendars/$calendarId"), + null, + null + ) + } + + /** + * Helper method used to return a URI for use with a sync adapter (how an application and a + * sync adapter access the Calendar Provider) + * + * @param uri URI to access the calendar + * @param account Name of the calendar owner + * + * @return URI of the calendar + * + */ + private fun asSyncAdapter(uri: Uri, account: String): Uri? { + return uri.buildUpon().appendQueryParameter(CalendarContract.CALLER_IS_SYNCADAPTER, "true") + .appendQueryParameter(CalendarContract.SyncState.ACCOUNT_NAME, account) + .appendQueryParameter( + CalendarContract.SyncState.ACCOUNT_TYPE, + CalendarContract.ACCOUNT_TYPE_LOCAL + ).build() + } + + fun openCalendarApp() { + val builder: Uri.Builder = CalendarContract.CONTENT_URI.buildUpon() + .appendPath("time") + ContentUris.appendId(builder, Calendar.getInstance().timeInMillis) + val intent = Intent(Intent.ACTION_VIEW).setData(builder.build()) + intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + context.startActivity(intent) + } + + /** + * Helper method used to check that the calendar if outdated for the course or not + * + * @param calendarTitle Title for the course Calendar + * @param courseDateBlocks Course dates events + * + * @return Calendar Id if Calendar is outdated otherwise -1 or CALENDAR_DOES_NOT_EXIST + * + */ + fun isCalendarOutOfDate( + calendarTitle: String, + courseDateBlocks: List + ): Long { + if (isCalendarExists(calendarTitle)) { + val calendarId = getCalendarId(calendarTitle) + if (compareEvents(calendarId, courseDateBlocks).not()) { + return calendarId + } + } + return CALENDAR_DOES_NOT_EXIST + } + + /** + * Method to get the current user account as the Calendar owner + * + * @return calendar owner account or "local_user" + */ + private fun getUserAccountForSync(): String { + return corePreferences.user?.email ?: LOCAL_USER + } + + /** + * Method to create the Calendar title for the platform against the course + * + * @param courseName Name of the course for that creating the Calendar events. + * + * @return title of the Calendar against the course + */ + fun getCourseCalendarTitle(courseName: String): String { + return "${resourceManager.getString(id = CoreR.string.platform_name)} - $courseName" + } + + companion object { + const val CALENDAR_DOES_NOT_EXIST = -1L + private const val TAG = "CalendarManager" + private const val LOCAL_USER = "local_user" + } +} diff --git a/course/src/main/java/org/openedx/course/presentation/calendarsync/CalendarSyncDialog.kt b/course/src/main/java/org/openedx/course/presentation/calendarsync/CalendarSyncDialog.kt new file mode 100644 index 000000000..59be5999b --- /dev/null +++ b/course/src/main/java/org/openedx/course/presentation/calendarsync/CalendarSyncDialog.kt @@ -0,0 +1,228 @@ +package org.openedx.course.presentation.calendarsync + +import android.content.res.Configuration +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.material.AlertDialog +import androidx.compose.material.CircularProgressIndicator +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.res.stringResource +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.tooling.preview.PreviewParameter +import androidx.compose.ui.tooling.preview.PreviewParameterProvider +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.Dialog +import org.openedx.core.extension.takeIfNotEmpty +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.course.R +import androidx.compose.ui.window.DialogProperties as AlertDialogProperties +import org.openedx.core.R as CoreR + +@Composable +fun CalendarSyncDialog( + syncDialogType: CalendarSyncDialogType, + calendarTitle: String, + syncDialogAction: (CalendarSyncDialogType) -> Unit, + dismissSyncDialog: () -> Unit, +) { + when (syncDialogType) { + CalendarSyncDialogType.SYNC_DIALOG, + CalendarSyncDialogType.UN_SYNC_DIALOG -> { + CalendarAlertDialog( + dialogProperties = DialogProperties( + title = stringResource(syncDialogType.titleResId), + message = stringResource(syncDialogType.messageResId, calendarTitle), + positiveButton = stringResource(syncDialogType.positiveButtonResId), + negativeButton = stringResource(syncDialogType.negativeButtonResId), + positiveAction = { syncDialogAction(syncDialogType) } + ), + onDismiss = dismissSyncDialog, + ) + } + + CalendarSyncDialogType.PERMISSION_DIALOG -> { + CalendarAlertDialog( + dialogProperties = DialogProperties( + title = stringResource( + syncDialogType.titleResId, + stringResource(CoreR.string.platform_name) + ), + message = stringResource( + syncDialogType.messageResId, + stringResource(CoreR.string.platform_name), + stringResource(CoreR.string.platform_name) + ), + positiveButton = stringResource(syncDialogType.positiveButtonResId), + negativeButton = stringResource(syncDialogType.negativeButtonResId), + positiveAction = { syncDialogAction(syncDialogType) } + ), + onDismiss = dismissSyncDialog + ) + } + + CalendarSyncDialogType.EVENTS_DIALOG -> { + CalendarAlertDialog( + dialogProperties = DialogProperties( + title = "", + message = stringResource(syncDialogType.messageResId, calendarTitle), + positiveButton = stringResource(syncDialogType.positiveButtonResId), + negativeButton = stringResource(syncDialogType.negativeButtonResId), + positiveAction = { syncDialogAction(syncDialogType) }, + ), + onDismiss = dismissSyncDialog + ) + } + + CalendarSyncDialogType.OUT_OF_SYNC_DIALOG -> { + CalendarAlertDialog( + dialogProperties = DialogProperties( + title = stringResource(syncDialogType.titleResId, calendarTitle), + message = stringResource(syncDialogType.messageResId), + positiveButton = stringResource(syncDialogType.positiveButtonResId), + negativeButton = stringResource(syncDialogType.negativeButtonResId), + positiveAction = { syncDialogAction(syncDialogType) }, + negativeAction = { syncDialogAction(CalendarSyncDialogType.UN_SYNC_DIALOG) } + ), + onDismiss = dismissSyncDialog + ) + } + + CalendarSyncDialogType.LOADING_DIALOG -> { + SyncDialog() + } + + CalendarSyncDialogType.NONE -> { + } + } +} + +@Composable +private fun CalendarAlertDialog(dialogProperties: DialogProperties, onDismiss: () -> Unit) { + AlertDialog( + modifier = Modifier.background( + color = MaterialTheme.appColors.background, + shape = MaterialTheme.appShapes.cardShape + ), + shape = MaterialTheme.appShapes.cardShape, + backgroundColor = MaterialTheme.appColors.background, + + properties = AlertDialogProperties( + dismissOnBackPress = false, + dismissOnClickOutside = false, + ), + onDismissRequest = onDismiss, + + title = dialogProperties.title.takeIfNotEmpty()?.let { + @Composable { + Text( + text = dialogProperties.title, + color = MaterialTheme.appColors.textPrimary, + style = MaterialTheme.appTypography.titleMedium, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + ) + } + }, + text = { + Text( + text = dialogProperties.message, + color = MaterialTheme.appColors.textPrimary, + style = MaterialTheme.appTypography.bodyMedium + ) + }, + confirmButton = { + TransparentTextButton( + text = dialogProperties.positiveButton + ) { + onDismiss() + dialogProperties.positiveAction.invoke() + } + }, + dismissButton = { + TransparentTextButton( + text = dialogProperties.negativeButton + ) { + onDismiss() + dialogProperties.negativeAction.invoke() + } + }, + ) +} + +@Composable +private fun SyncDialog() { + Dialog( + onDismissRequest = { }, + properties = androidx.compose.ui.window.DialogProperties( + dismissOnBackPress = false, + dismissOnClickOutside = false, + ), + content = { + Surface( + modifier = Modifier + .fillMaxWidth() + .clip(MaterialTheme.appShapes.cardShape), + shape = MaterialTheme.appShapes.cardShape, + color = MaterialTheme.appColors.background, + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + Text( + text = stringResource(id = R.string.course_title_syncing_calendar), + color = MaterialTheme.appColors.textPrimary, + style = MaterialTheme.appTypography.titleMedium, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + ) + Box( + modifier = Modifier + .fillMaxWidth() + .padding(30.dp), + contentAlignment = Alignment.Center + ) { + CircularProgressIndicator(color = MaterialTheme.appColors.primary) + } + } + } + } + ) +} + +@Composable +@Preview(uiMode = Configuration.UI_MODE_NIGHT_NO) +@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES) +private fun CalendarSyncDialogsPreview( + @PreviewParameter(CalendarSyncDialogTypeProvider::class) dialogType: CalendarSyncDialogType +) { + OpenEdXTheme { + CalendarSyncDialog( + syncDialogType = dialogType, + calendarTitle = "Hello to OpenEdx", + syncDialogAction = {}, + dismissSyncDialog = {}, + ) + } +} + +private class CalendarSyncDialogTypeProvider : PreviewParameterProvider { + override val values = CalendarSyncDialogType.values().dropLast(1).asSequence() +} diff --git a/course/src/main/java/org/openedx/course/presentation/calendarsync/CalendarSyncDialogType.kt b/course/src/main/java/org/openedx/course/presentation/calendarsync/CalendarSyncDialogType.kt new file mode 100644 index 000000000..57d6c0dac --- /dev/null +++ b/course/src/main/java/org/openedx/course/presentation/calendarsync/CalendarSyncDialogType.kt @@ -0,0 +1,45 @@ +package org.openedx.course.presentation.calendarsync + +import org.openedx.course.R +import org.openedx.core.R as CoreR + +enum class CalendarSyncDialogType( + val titleResId: Int = 0, + val messageResId: Int = 0, + val positiveButtonResId: Int = 0, + val negativeButtonResId: Int = 0, +) { + SYNC_DIALOG( + titleResId = R.string.course_title_add_course_calendar, + messageResId = R.string.course_message_add_course_calendar, + positiveButtonResId = CoreR.string.core_ok, + negativeButtonResId = CoreR.string.core_cancel + ), + UN_SYNC_DIALOG( + titleResId = R.string.course_title_remove_course_calendar, + messageResId = R.string.course_message_remove_course_calendar, + positiveButtonResId = R.string.course_label_remove, + negativeButtonResId = CoreR.string.core_cancel + ), + PERMISSION_DIALOG( + titleResId = R.string.course_title_request_calendar_permission, + messageResId = R.string.course_message_request_calendar_permission, + positiveButtonResId = CoreR.string.core_ok, + negativeButtonResId = R.string.course_label_do_not_allow + ), + EVENTS_DIALOG( + messageResId = R.string.course_message_course_calendar_added, + positiveButtonResId = R.string.course_label_view_events, + negativeButtonResId = R.string.course_label_done + ), + OUT_OF_SYNC_DIALOG( + titleResId = R.string.course_title_calendar_out_of_date, + messageResId = R.string.course_message_calendar_out_of_date, + positiveButtonResId = R.string.course_label_update_now, + negativeButtonResId = R.string.course_label_remove_course_calendar, + ), + LOADING_DIALOG( + titleResId = R.string.course_title_syncing_calendar + ), + NONE; +} diff --git a/course/src/main/java/org/openedx/course/presentation/calendarsync/CalendarSyncUIState.kt b/course/src/main/java/org/openedx/course/presentation/calendarsync/CalendarSyncUIState.kt new file mode 100644 index 000000000..24d2212e2 --- /dev/null +++ b/course/src/main/java/org/openedx/course/presentation/calendarsync/CalendarSyncUIState.kt @@ -0,0 +1,14 @@ +package org.openedx.course.presentation.calendarsync + +import org.openedx.core.domain.model.CourseDateBlock +import java.util.concurrent.atomic.AtomicReference + +data class CalendarSyncUIState( + val isCalendarSyncEnabled: Boolean = false, + val calendarTitle: String = "", + val courseDates: List = listOf(), + val dialogType: CalendarSyncDialogType = CalendarSyncDialogType.NONE, + val isSynced: Boolean = false, + val checkForOutOfSync: AtomicReference = AtomicReference(false), + val uiMessage: AtomicReference = AtomicReference(""), +) diff --git a/course/src/main/java/org/openedx/course/presentation/calendarsync/DialogProperties.kt b/course/src/main/java/org/openedx/course/presentation/calendarsync/DialogProperties.kt new file mode 100644 index 000000000..cefded76c --- /dev/null +++ b/course/src/main/java/org/openedx/course/presentation/calendarsync/DialogProperties.kt @@ -0,0 +1,10 @@ +package org.openedx.course.presentation.calendarsync + +data class DialogProperties( + val title: String, + val message: String, + val positiveButton: String, + val negativeButton: String, + val positiveAction: () -> Unit, + val negativeAction: () -> Unit = {}, +) diff --git a/course/src/main/java/org/openedx/course/presentation/container/CourseContainerFragment.kt b/course/src/main/java/org/openedx/course/presentation/container/CourseContainerFragment.kt index 936e75294..a5f22084b 100644 --- a/course/src/main/java/org/openedx/course/presentation/container/CourseContainerFragment.kt +++ b/course/src/main/java/org/openedx/course/presentation/container/CourseContainerFragment.kt @@ -2,6 +2,11 @@ package org.openedx.course.presentation.container import android.os.Bundle import android.view.View +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.ui.platform.ViewCompositionStrategy import androidx.core.os.bundleOf import androidx.core.view.isVisible import androidx.fragment.app.Fragment @@ -11,10 +16,14 @@ import com.google.android.material.tabs.TabLayoutMediator 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.extension.takeIfNotEmpty import org.openedx.core.presentation.global.viewBinding +import org.openedx.core.ui.theme.OpenEdXTheme import org.openedx.course.R import org.openedx.course.databinding.FragmentCourseContainerBinding import org.openedx.course.presentation.CourseRouter +import org.openedx.course.presentation.calendarsync.CalendarSyncDialog +import org.openedx.course.presentation.calendarsync.CalendarSyncDialogType import org.openedx.course.presentation.container.CourseContainerTab import org.openedx.course.presentation.dates.CourseDatesFragment import org.openedx.course.presentation.handouts.HandoutsFragment @@ -37,6 +46,14 @@ class CourseContainerFragment : Fragment(R.layout.fragment_course_container) { private var adapter: CourseContainerAdapter? = null + private val permissionLauncher = registerForActivityResult( + ActivityResultContracts.RequestMultiplePermissions() + ) { isGranted -> + if (!isGranted.containsValue(false)) { + viewModel.setCalendarSyncDialogType(CalendarSyncDialogType.SYNC_DIALOG) + } + } + override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) viewModel.preloadCourseStructure() @@ -47,6 +64,9 @@ class CourseContainerFragment : Fragment(R.layout.fragment_course_container) { override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) setupToolbar(viewModel.courseName) + if (viewModel.calendarSyncUIState.value.isCalendarSyncEnabled) { + setUpCourseCalendar() + } observe() } @@ -109,7 +129,11 @@ class CourseContainerFragment : Fragment(R.layout.fragment_course_container) { ) addFragment( Tabs.DATES, - CourseDatesFragment.newInstance(viewModel.courseId, viewModel.isSelfPaced) + CourseDatesFragment.newInstance( + viewModel.courseId, + viewModel.courseName, + viewModel.isSelfPaced + ) ) addFragment( Tabs.HANDOUTS, @@ -141,6 +165,69 @@ class CourseContainerFragment : Fragment(R.layout.fragment_course_container) { } } + private fun setUpCourseCalendar() { + binding.composeContainer.apply { + setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed) + setContent { + OpenEdXTheme { + val syncState by viewModel.calendarSyncUIState.collectAsState() + + LaunchedEffect(key1 = syncState.checkForOutOfSync) { + if (syncState.isCalendarSyncEnabled && syncState.checkForOutOfSync.get()) { + viewModel.checkIfCalendarOutOfDate() + } + } + + LaunchedEffect(syncState.uiMessage.get()) { + syncState.uiMessage.get().takeIfNotEmpty()?.let { + Snackbar.make(binding.root, it, Snackbar.LENGTH_SHORT).show() + syncState.uiMessage.set("") + } + } + + CalendarSyncDialog( + syncDialogType = syncState.dialogType, + calendarTitle = syncState.calendarTitle, + syncDialogAction = { dialog -> + when (dialog) { + CalendarSyncDialogType.SYNC_DIALOG -> { + viewModel.addOrUpdateEventsInCalendar( + updatedEvent = false, + ) + } + + CalendarSyncDialogType.UN_SYNC_DIALOG -> { + viewModel.deleteCourseCalendar() + } + + CalendarSyncDialogType.PERMISSION_DIALOG -> { + permissionLauncher.launch(viewModel.calendarPermissions) + } + + CalendarSyncDialogType.OUT_OF_SYNC_DIALOG -> { + viewModel.addOrUpdateEventsInCalendar( + updatedEvent = true, + ) + } + + CalendarSyncDialogType.EVENTS_DIALOG -> { + viewModel.openCalendarApp() + } + + CalendarSyncDialogType.LOADING_DIALOG, + CalendarSyncDialogType.NONE -> { + } + } + }, + dismissSyncDialog = { + viewModel.setCalendarSyncDialogType(CalendarSyncDialogType.NONE) + } + ) + } + } + } + } + fun updateCourseStructure(withSwipeRefresh: Boolean) { viewModel.updateData(withSwipeRefresh) } diff --git a/course/src/main/java/org/openedx/course/presentation/container/CourseContainerViewModel.kt b/course/src/main/java/org/openedx/course/presentation/container/CourseContainerViewModel.kt index f0d9a9507..886c63319 100644 --- a/course/src/main/java/org/openedx/course/presentation/container/CourseContainerViewModel.kt +++ b/course/src/main/java/org/openedx/course/presentation/container/CourseContainerViewModel.kt @@ -1,33 +1,53 @@ package org.openedx.course.presentation.container +import androidx.annotation.StringRes import androidx.lifecycle.LiveData import androidx.lifecycle.MutableLiveData import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import org.openedx.core.BaseViewModel -import org.openedx.core.R import org.openedx.core.SingleEventLiveData import org.openedx.core.config.Config +import org.openedx.core.data.storage.CorePreferences import org.openedx.core.exception.NoCachedDataException 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.CalendarSyncEvent.CheckCalendarSyncEvent +import org.openedx.core.system.notifier.CalendarSyncEvent.CreateCalendarSyncEvent import org.openedx.core.system.notifier.CourseCompletionSet import org.openedx.core.system.notifier.CourseNotifier import org.openedx.core.system.notifier.CourseStructureUpdated +import org.openedx.core.utils.TimeUtils +import org.openedx.course.R +import org.openedx.course.data.storage.CoursePreferences import org.openedx.course.domain.interactor.CourseInteractor import org.openedx.course.presentation.CourseAnalytics +import org.openedx.course.presentation.calendarsync.CalendarManager +import org.openedx.course.presentation.calendarsync.CalendarSyncDialogType +import org.openedx.course.presentation.calendarsync.CalendarSyncUIState import java.util.Date +import java.util.concurrent.atomic.AtomicReference +import org.openedx.core.R as CoreR class CourseContainerViewModel( val courseId: String, var courseName: String, private val config: Config, private val interactor: CourseInteractor, + private val calendarManager: CalendarManager, private val resourceManager: ResourceManager, private val notifier: CourseNotifier, private val networkConnection: NetworkConnection, - private val analytics: CourseAnalytics + private val analytics: CourseAnalytics, + private val corePreferences: CorePreferences, + private val coursePreferences: CoursePreferences, ) : BaseViewModel() { val isCourseTopTabBarEnabled get() = config.isCourseTopTabBarEnabled() @@ -48,12 +68,39 @@ class CourseContainerViewModel( val isSelfPaced: Boolean get() = _isSelfPaced + val calendarPermissions: Array + get() = calendarManager.permissions + + private val _calendarSyncUIState = MutableStateFlow( + CalendarSyncUIState( + isCalendarSyncEnabled = isCalendarSyncEnabled(), + calendarTitle = calendarManager.getCourseCalendarTitle(courseName), + courseDates = emptyList(), + dialogType = CalendarSyncDialogType.NONE, + checkForOutOfSync = AtomicReference(false), + uiMessage = AtomicReference(""), + ) + ) + val calendarSyncUIState: StateFlow = + _calendarSyncUIState.asStateFlow() + init { viewModelScope.launch { notifier.notifier.collect { event -> if (event is CourseCompletionSet) { updateData(false) } + + if (event is CreateCalendarSyncEvent) { + _calendarSyncUIState.update { + val dialogType = CalendarSyncDialogType.valueOf(event.dialogType) + it.copy( + courseDates = event.courseDates, + dialogType = dialogType, + checkForOutOfSync = AtomicReference(event.checkOutOfSync) + ) + } + } } } } @@ -80,10 +127,10 @@ class CourseContainerViewModel( } catch (e: Exception) { if (e.isInternetError() || e is NoCachedDataException) { _errorMessage.value = - resourceManager.getString(R.string.core_error_no_connection) + resourceManager.getString(CoreR.string.core_error_no_connection) } else { _errorMessage.value = - resourceManager.getString(R.string.core_error_unknown_error) + resourceManager.getString(CoreR.string.core_error_unknown_error) } } _showProgress.value = false @@ -98,10 +145,10 @@ class CourseContainerViewModel( } catch (e: Exception) { if (e.isInternetError()) { _errorMessage.value = - resourceManager.getString(R.string.core_error_no_connection) + resourceManager.getString(CoreR.string.core_error_no_connection) } else { _errorMessage.value = - resourceManager.getString(R.string.core_error_unknown_error) + resourceManager.getString(CoreR.string.core_error_unknown_error) } } _showProgress.value = false @@ -119,6 +166,122 @@ class CourseContainerViewModel( } } + fun setCalendarSyncDialogType(dialogType: CalendarSyncDialogType) { + val currentState = _calendarSyncUIState.value + if (currentState.dialogType != dialogType) { + _calendarSyncUIState.value = currentState.copy(dialogType = dialogType) + } + } + + fun addOrUpdateEventsInCalendar( + updatedEvent: Boolean, + ) { + setCalendarSyncDialogType(CalendarSyncDialogType.LOADING_DIALOG) + + val startSyncTime = TimeUtils.getCurrentTime() + val calendarId = getCalendarId() + + if (calendarId == CalendarManager.CALENDAR_DOES_NOT_EXIST) { + setUiMessage(R.string.course_snackbar_course_calendar_error) + setCalendarSyncDialogType(CalendarSyncDialogType.NONE) + + return + } + + viewModelScope.launch(Dispatchers.IO) { + val courseDates = _calendarSyncUIState.value.courseDates + if (courseDates.isNotEmpty()) { + courseDates.forEach { courseDateBlock -> + calendarManager.addEventsIntoCalendar( + calendarId = calendarId, + courseId = courseId, + courseName = courseName, + courseDateBlock = courseDateBlock + ) + } + } + val elapsedSyncTime = TimeUtils.getCurrentTime() - startSyncTime + val delayRemaining = maxOf(0, 1000 - elapsedSyncTime) + + // Ensure minimum 1s delay to prevent flicker for rapid event creation + if (delayRemaining > 0) { + delay(delayRemaining) + } + + setCalendarSyncDialogType(CalendarSyncDialogType.NONE) + updateCalendarSyncState() + + if (updatedEvent) { + setUiMessage(R.string.course_snackbar_course_calendar_updated) + } else if (coursePreferences.isCalendarSyncEventsDialogShown(courseName)) { + setUiMessage(R.string.course_snackbar_course_calendar_added) + } else { + coursePreferences.setCalendarSyncEventsDialogShown(courseName) + setCalendarSyncDialogType(CalendarSyncDialogType.EVENTS_DIALOG) + } + } + } + + private fun updateCalendarSyncState() { + viewModelScope.launch { + val isCalendarSynced = calendarManager.isCalendarExists( + calendarTitle = _calendarSyncUIState.value.calendarTitle + ) + notifier.send(CheckCalendarSyncEvent(isSynced = isCalendarSynced)) + } + } + + fun checkIfCalendarOutOfDate() { + val courseDates = _calendarSyncUIState.value.courseDates + if (courseDates.isNotEmpty()) { + _calendarSyncUIState.value.checkForOutOfSync.set(false) + val outdatedCalendarId = calendarManager.isCalendarOutOfDate( + calendarTitle = _calendarSyncUIState.value.calendarTitle, + courseDateBlocks = courseDates + ) + if (outdatedCalendarId != CalendarManager.CALENDAR_DOES_NOT_EXIST) { + setCalendarSyncDialogType(CalendarSyncDialogType.OUT_OF_SYNC_DIALOG) + } + } + } + + fun deleteCourseCalendar() { + if (calendarManager.hasPermissions()) { + viewModelScope.launch(Dispatchers.IO) { + val calendarId = getCalendarId() + if (calendarId != CalendarManager.CALENDAR_DOES_NOT_EXIST) { + calendarManager.deleteCalendar( + calendarId = calendarId, + ) + } + updateCalendarSyncState() + } + setUiMessage(R.string.course_snackbar_course_calendar_removed) + } + } + + fun openCalendarApp() { + calendarManager.openCalendarApp() + } + + private fun setUiMessage(@StringRes stringResId: Int) { + _calendarSyncUIState.update { + it.copy(uiMessage = AtomicReference(resourceManager.getString(stringResId))) + } + } + + private fun getCalendarId(): Long { + return calendarManager.createOrUpdateCalendar( + calendarTitle = _calendarSyncUIState.value.calendarTitle + ) + } + + private fun isCalendarSyncEnabled(): Boolean { + val calendarSync = corePreferences.appConfig.courseDatesCalendarSync + return calendarSync.isEnabled && ((calendarSync.isSelfPacedEnabled && isSelfPaced) || + (calendarSync.isInstructorPacedEnabled && !isSelfPaced)) + } + private fun courseTabClickedEvent() { analytics.courseTabClickedEvent(courseId, courseName) } diff --git a/course/src/main/java/org/openedx/course/presentation/dates/CourseDatesFragment.kt b/course/src/main/java/org/openedx/course/presentation/dates/CourseDatesFragment.kt index 98d4b4d62..39a342634 100644 --- a/course/src/main/java/org/openedx/course/presentation/dates/CourseDatesFragment.kt +++ b/course/src/main/java/org/openedx/course/presentation/dates/CourseDatesFragment.kt @@ -15,6 +15,7 @@ import androidx.compose.animation.shrinkVertically 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.IntrinsicSize @@ -37,6 +38,8 @@ import androidx.compose.material.Icon import androidx.compose.material.MaterialTheme import androidx.compose.material.Scaffold import androidx.compose.material.Surface +import androidx.compose.material.Switch +import androidx.compose.material.SwitchDefaults import androidx.compose.material.Text import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.KeyboardArrowRight @@ -46,6 +49,7 @@ 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.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.livedata.observeAsState import androidx.compose.runtime.mutableStateOf @@ -88,18 +92,20 @@ 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.openedx.core.ui.windowSizeValue import org.openedx.core.utils.TimeUtils import org.openedx.core.utils.clearTime import org.openedx.course.R import org.openedx.course.presentation.CourseRouter +import org.openedx.course.presentation.calendarsync.CalendarSyncUIState import org.openedx.course.presentation.container.CourseContainerFragment import org.openedx.course.presentation.ui.CourseDatesBanner import org.openedx.course.presentation.ui.CourseDatesBannerTablet +import java.util.concurrent.atomic.AtomicReference import org.openedx.core.R as coreR class CourseDatesFragment : Fragment() { @@ -107,11 +113,18 @@ class CourseDatesFragment : Fragment() { private val viewModel by viewModel { parametersOf( requireArguments().getString(ARG_COURSE_ID, ""), + requireArguments().getString(ARG_COURSE_NAME, ""), requireArguments().getBoolean(ARG_IS_SELF_PACED, true), ) } private val router by inject() + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + viewModel.updateAndFetchCalendarSyncState() + } + override fun onCreateView( inflater: LayoutInflater, container: ViewGroup?, @@ -121,9 +134,10 @@ class CourseDatesFragment : Fragment() { setContent { OpenEdXTheme { val windowSize = rememberWindowSize() - val uiState by viewModel.uiState.observeAsState() + val uiState by viewModel.uiState.observeAsState(DatesUIState.Loading) val uiMessage by viewModel.uiMessage.observeAsState() val refreshing by viewModel.updating.observeAsState(false) + val calendarSyncUIState by viewModel.calendarSyncUIState.collectAsState() CourseDatesScreen( windowSize = windowSize, @@ -132,6 +146,7 @@ class CourseDatesFragment : Fragment() { refreshing = refreshing, isSelfPaced = viewModel.isSelfPaced, hasInternetConnection = viewModel.hasInternetConnection, + calendarSyncUIState = calendarSyncUIState, onReloadClick = { viewModel.getCourseDates() }, @@ -162,6 +177,9 @@ class CourseDatesFragment : Fragment() { } } }, + onCalendarSyncSwitch = { isChecked -> + viewModel.handleCalendarSyncState(isChecked) + }, ) } } @@ -173,15 +191,19 @@ class CourseDatesFragment : Fragment() { companion object { private const val ARG_COURSE_ID = "courseId" + private const val ARG_COURSE_NAME = "courseName" private const val ARG_IS_SELF_PACED = "selfPaced" + fun newInstance( courseId: String, + courseName: String, isSelfPaced: Boolean, ): CourseDatesFragment { val fragment = CourseDatesFragment() fragment.arguments = bundleOf( ARG_COURSE_ID to courseId, + ARG_COURSE_NAME to courseName, ARG_IS_SELF_PACED to isSelfPaced, ) return fragment @@ -193,15 +215,17 @@ class CourseDatesFragment : Fragment() { @Composable internal fun CourseDatesScreen( windowSize: WindowSize, - uiState: DatesUIState?, + uiState: DatesUIState, uiMessage: UIMessage?, refreshing: Boolean, isSelfPaced: Boolean, hasInternetConnection: Boolean, + calendarSyncUIState: CalendarSyncUIState, onReloadClick: () -> Unit, onSwipeRefresh: () -> Unit, onItemClick: (String) -> Unit, onSyncDates: () -> Unit, + onCalendarSyncSwitch: (Boolean) -> Unit = {}, ) { val scaffoldState = rememberScaffoldState() val pullRefreshState = @@ -240,7 +264,6 @@ internal fun CourseDatesScreen( modifier = Modifier .fillMaxSize() .padding(it) - .statusBarsInset() .displayCutoutForLandscape(), contentAlignment = Alignment.TopCenter ) { Surface( @@ -252,89 +275,98 @@ internal fun CourseDatesScreen( .fillMaxWidth() .pullRefresh(pullRefreshState) ) { - uiState?.let { - when (uiState) { - is DatesUIState.Loading -> { - Box( - modifier = Modifier.fillMaxSize(), - contentAlignment = Alignment.Center - ) { - CircularProgressIndicator(color = MaterialTheme.appColors.primary) - } + when (uiState) { + is DatesUIState.Loading -> { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center + ) { + CircularProgressIndicator(color = MaterialTheme.appColors.primary) } + } - is DatesUIState.Dates -> { - LazyColumn( - modifier = Modifier - .fillMaxSize() - .padding(horizontal = 16.dp), - contentPadding = listBottomPadding - ) { - val courseBanner = uiState.courseDatesResult.courseBanner - val datesSection = uiState.courseDatesResult.datesSection - - if (courseBanner.isBannerAvailableForUserType(isSelfPaced)) { - item { - if (windowSize.isTablet) { - CourseDatesBannerTablet( - modifier = Modifier.padding(bottom = 16.dp), - banner = courseBanner, - resetDates = onSyncDates, - ) - } else { - CourseDatesBanner( - modifier = Modifier.padding(bottom = 16.dp), - banner = courseBanner, - resetDates = onSyncDates - ) - } + is DatesUIState.Dates -> { + LazyColumn( + modifier = Modifier + .fillMaxSize() + .padding(horizontal = 16.dp), + contentPadding = listBottomPadding + ) { + val courseBanner = uiState.courseDatesResult.courseBanner + val datesSection = uiState.courseDatesResult.datesSection + + if (calendarSyncUIState.isCalendarSyncEnabled) { + item { + CalendarSyncCard( + modifier = Modifier.padding(top = 24.dp), + checked = calendarSyncUIState.isSynced, + onCalendarSync = onCalendarSyncSwitch + ) + } + } + + if (courseBanner.isBannerAvailableForUserType(isSelfPaced)) { + item { + if (windowSize.isTablet) { + CourseDatesBannerTablet( + modifier = Modifier.padding(top = 16.dp), + banner = courseBanner, + resetDates = onSyncDates, + ) + } else { + CourseDatesBanner( + modifier = Modifier.padding(top = 16.dp), + banner = courseBanner, + resetDates = onSyncDates + ) } } + } - // Handle DatesSection.COMPLETED separately - datesSection[DatesSection.COMPLETED]?.isNotEmptyThenLet { section -> + // Handle DatesSection.COMPLETED separately + datesSection[DatesSection.COMPLETED]?.isNotEmptyThenLet { section -> + item { + ExpandableView( + sectionKey = DatesSection.COMPLETED, + sectionDates = section, + onItemClick = onItemClick, + ) + } + } + + // Handle other sections + val sectionsKey = + datesSection.keys.minus(DatesSection.COMPLETED).toList() + sectionsKey.forEach { sectionKey -> + datesSection[sectionKey]?.isNotEmptyThenLet { section -> item { - ExpandableView( - sectionKey = DatesSection.COMPLETED, + CourseDateBlockSection( + sectionKey = sectionKey, sectionDates = section, onItemClick = onItemClick, ) } } - - // Handle other sections - val sectionsKey = - datesSection.keys.minus(DatesSection.COMPLETED).toList() - sectionsKey.forEach { sectionKey -> - datesSection[sectionKey]?.isNotEmptyThenLet { section -> - item { - CourseDateBlockSection( - sectionKey = sectionKey, - sectionDates = section, - onItemClick = onItemClick, - ) - } - } - } } } + } - DatesUIState.Empty -> { - Box( - modifier = Modifier.fillMaxSize(), - contentAlignment = Alignment.Center - ) { - Text( - modifier = Modifier.fillMaxWidth(), - text = stringResource(id = R.string.course_dates_unavailable_message), - color = MaterialTheme.appColors.textPrimary, - style = MaterialTheme.appTypography.titleMedium, - textAlign = TextAlign.Center - ) - } + DatesUIState.Empty -> { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center + ) { + Text( + modifier = Modifier.fillMaxWidth(), + text = stringResource(id = R.string.course_dates_unavailable_message), + color = MaterialTheme.appColors.textPrimary, + style = MaterialTheme.appTypography.titleMedium, + textAlign = TextAlign.Center + ) } } } + PullRefreshIndicator( refreshing, pullRefreshState, Modifier.align(Alignment.TopCenter) ) @@ -357,6 +389,68 @@ internal fun CourseDatesScreen( } } +@Composable +fun CalendarSyncCard( + modifier: Modifier = Modifier, + checked: Boolean, + onCalendarSync: (Boolean) -> Unit, +) { + val cardModifier = modifier + .background( + MaterialTheme.appColors.cardViewBackground, + MaterialTheme.appShapes.material.medium + ) + .border( + 1.dp, + MaterialTheme.appColors.cardViewBorder, + MaterialTheme.appShapes.material.medium + ) + .padding(16.dp) + + Column(modifier = cardModifier) { + Row( + modifier = Modifier + .fillMaxWidth() + .height(40.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.Start + ) { + Icon( + painter = painterResource(id = R.drawable.course_ic_calenday_sync), + contentDescription = null, + modifier = Modifier.size(24.dp) + ) + Text( + modifier = Modifier + .padding(start = 8.dp, end = 8.dp) + .weight(1f), + text = stringResource(id = R.string.course_header_sync_to_calendar), + style = MaterialTheme.appTypography.titleMedium, + color = MaterialTheme.appColors.textDark + ) + Switch( + checked = checked, + onCheckedChange = onCalendarSync, + modifier = Modifier.size(48.dp), + colors = SwitchDefaults.colors( + checkedThumbColor = MaterialTheme.appColors.primary, + checkedTrackColor = MaterialTheme.appColors.primary + ) + ) + } + + Text( + modifier = Modifier + .fillMaxWidth() + .padding(top = 8.dp) + .height(40.dp), + text = stringResource(id = R.string.course_body_sync_to_calendar), + style = MaterialTheme.appTypography.bodyMedium, + color = MaterialTheme.appColors.textDark, + ) + } +} + @Composable fun ExpandableView( sectionKey: DatesSection = DatesSection.NONE, @@ -382,6 +476,7 @@ fun ExpandableView( Box( modifier = Modifier .fillMaxWidth() + .padding(top = 16.dp) .background(MaterialTheme.appColors.cardViewBackground, MaterialTheme.shapes.medium) .border(0.75.dp, MaterialTheme.appColors.cardViewBorder, MaterialTheme.shapes.medium) ) { @@ -618,13 +713,15 @@ private fun CourseDatesScreenPreview() { windowSize = WindowSize(WindowType.Compact, WindowType.Compact), uiState = DatesUIState.Dates(CourseDatesResult(mockedResponse, mockedCourseBannerInfo)), uiMessage = null, + refreshing = false, isSelfPaced = true, hasInternetConnection = true, - refreshing = false, - onSwipeRefresh = {}, + calendarSyncUIState = mockCalendarSyncUIState, onReloadClick = {}, + onSwipeRefresh = {}, onItemClick = {}, onSyncDates = {}, + onCalendarSyncSwitch = {}, ) } } @@ -638,13 +735,15 @@ private fun CourseDatesScreenTabletPreview() { windowSize = WindowSize(WindowType.Medium, WindowType.Medium), uiState = DatesUIState.Dates(CourseDatesResult(mockedResponse, mockedCourseBannerInfo)), uiMessage = null, + refreshing = false, isSelfPaced = true, hasInternetConnection = true, - refreshing = false, - onSwipeRefresh = {}, + calendarSyncUIState = mockCalendarSyncUIState, onReloadClick = {}, + onSwipeRefresh = {}, onItemClick = {}, onSyncDates = {}, + onCalendarSyncSwitch = {}, ) } } @@ -730,3 +829,9 @@ private val mockedResponse: LinkedHashMap> = ) ) ) + +val mockCalendarSyncUIState = CalendarSyncUIState( + isCalendarSyncEnabled = true, + isSynced = true, + checkForOutOfSync = AtomicReference() +) diff --git a/course/src/main/java/org/openedx/course/presentation/dates/CourseDatesViewModel.kt b/course/src/main/java/org/openedx/course/presentation/dates/CourseDatesViewModel.kt index 8d643de9d..2380dbab4 100644 --- a/course/src/main/java/org/openedx/course/presentation/dates/CourseDatesViewModel.kt +++ b/course/src/main/java/org/openedx/course/presentation/dates/CourseDatesViewModel.kt @@ -3,25 +3,40 @@ package org.openedx.course.presentation.dates import androidx.lifecycle.LiveData import androidx.lifecycle.MutableLiveData import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update 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.data.storage.CorePreferences import org.openedx.core.domain.model.Block import org.openedx.core.extension.getSequentialBlocks import org.openedx.core.extension.getVerticalBlocks 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.CalendarSyncEvent.CheckCalendarSyncEvent +import org.openedx.core.system.notifier.CalendarSyncEvent.CreateCalendarSyncEvent +import org.openedx.core.system.notifier.CourseNotifier import org.openedx.course.domain.interactor.CourseInteractor +import org.openedx.course.presentation.calendarsync.CalendarManager +import org.openedx.course.presentation.calendarsync.CalendarSyncDialogType +import org.openedx.course.presentation.calendarsync.CalendarSyncUIState +import org.openedx.core.R as CoreR class CourseDatesViewModel( val courseId: String, + var courseName: String, val isSelfPaced: Boolean, + private val notifier: CourseNotifier, private val interactor: CourseInteractor, + private val calendarManager: CalendarManager, private val networkConnection: NetworkConnection, private val resourceManager: ResourceManager, + private val corePreferences: CorePreferences, ) : BaseViewModel() { private val _uiState = MutableLiveData(DatesUIState.Loading) @@ -32,6 +47,16 @@ class CourseDatesViewModel( val uiMessage: LiveData get() = _uiMessage + private val _calendarSyncUIState = MutableStateFlow( + CalendarSyncUIState( + isCalendarSyncEnabled = isCalendarSyncEnabled(), + calendarTitle = calendarManager.getCourseCalendarTitle(courseName), + isSynced = false, + ) + ) + val calendarSyncUIState: StateFlow = + _calendarSyncUIState.asStateFlow() + private val _updating = MutableLiveData() val updating: LiveData get() = _updating @@ -41,6 +66,13 @@ class CourseDatesViewModel( init { getCourseDates() + viewModelScope.launch { + notifier.notifier.collect { event -> + if (event is CheckCalendarSyncEvent) { + _calendarSyncUIState.update { it.copy(isSynced = event.isSynced) } + } + } + } } fun getCourseDates(swipeToRefresh: Boolean = false) { @@ -59,14 +91,15 @@ class CourseDatesViewModel( _uiState.value = DatesUIState.Empty } else { _uiState.value = DatesUIState.Dates(datesResponse) + checkIfCalendarOutOfDate() } } catch (e: Exception) { if (e.isInternetError()) { _uiMessage.value = - UIMessage.SnackBarMessage(resourceManager.getString(R.string.core_error_no_connection)) + UIMessage.SnackBarMessage(resourceManager.getString(CoreR.string.core_error_no_connection)) } else { _uiMessage.value = - UIMessage.SnackBarMessage(resourceManager.getString(R.string.core_error_unknown_error)) + UIMessage.SnackBarMessage(resourceManager.getString(CoreR.string.core_error_unknown_error)) } } _updating.value = false @@ -82,10 +115,10 @@ class CourseDatesViewModel( } catch (e: Exception) { if (e.isInternetError()) { _uiMessage.value = - UIMessage.SnackBarMessage(resourceManager.getString(R.string.core_error_no_connection)) + UIMessage.SnackBarMessage(resourceManager.getString(CoreR.string.core_error_no_connection)) } else { _uiMessage.value = - UIMessage.SnackBarMessage(resourceManager.getString(R.string.core_error_unknown_error)) + UIMessage.SnackBarMessage(resourceManager.getString(CoreR.string.core_error_unknown_error)) } onResetDates(false) } @@ -110,4 +143,58 @@ class CourseDatesViewModel( null } } + + fun handleCalendarSyncState(isChecked: Boolean) { + setCalendarSyncDialogType( + when { + isChecked && calendarManager.hasPermissions() -> CalendarSyncDialogType.SYNC_DIALOG + isChecked -> CalendarSyncDialogType.PERMISSION_DIALOG + else -> CalendarSyncDialogType.UN_SYNC_DIALOG + } + ) + } + + fun updateAndFetchCalendarSyncState(): Boolean { + val isCalendarSynced = calendarManager.isCalendarExists( + calendarTitle = _calendarSyncUIState.value.calendarTitle + ) + _calendarSyncUIState.update { it.copy(isSynced = isCalendarSynced) } + return isCalendarSynced + } + + private fun setCalendarSyncDialogType(dialog: CalendarSyncDialogType) { + val value = _uiState.value + if (value is DatesUIState.Dates) { + viewModelScope.launch { + notifier.send( + CreateCalendarSyncEvent( + courseDates = value.courseDatesResult.datesSection.values.flatten(), + dialogType = dialog.name, + checkOutOfSync = false, + ) + ) + } + } + } + + private fun checkIfCalendarOutOfDate() { + val value = _uiState.value + if (value is DatesUIState.Dates) { + viewModelScope.launch { + notifier.send( + CreateCalendarSyncEvent( + courseDates = value.courseDatesResult.datesSection.values.flatten(), + dialogType = CalendarSyncDialogType.NONE.name, + checkOutOfSync = true, + ) + ) + } + } + } + + private fun isCalendarSyncEnabled(): Boolean { + val calendarSync = corePreferences.appConfig.courseDatesCalendarSync + return calendarSync.isEnabled && ((calendarSync.isSelfPacedEnabled && isSelfPaced) || + (calendarSync.isInstructorPacedEnabled && !isSelfPaced)) + } } diff --git a/course/src/main/java/org/openedx/course/presentation/outline/CourseOutlineViewModel.kt b/course/src/main/java/org/openedx/course/presentation/outline/CourseOutlineViewModel.kt index 361e04be0..cf83fd041 100644 --- a/course/src/main/java/org/openedx/course/presentation/outline/CourseOutlineViewModel.kt +++ b/course/src/main/java/org/openedx/course/presentation/outline/CourseOutlineViewModel.kt @@ -13,7 +13,9 @@ 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.CourseComponentStatus +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.extension.getSequentialBlocks import org.openedx.core.extension.getVerticalBlocks import org.openedx.core.extension.isInternetError @@ -22,10 +24,12 @@ import org.openedx.core.module.db.DownloadDao import org.openedx.core.module.download.BaseDownloadViewModel import org.openedx.core.system.ResourceManager import org.openedx.core.system.connection.NetworkConnection +import org.openedx.core.system.notifier.CalendarSyncEvent.CreateCalendarSyncEvent 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 org.openedx.course.presentation.calendarsync.CalendarSyncDialogType import org.openedx.course.R as courseR class CourseOutlineViewModel( @@ -169,17 +173,24 @@ class CourseOutlineViewModel( CourseComponentStatus("") } - val datesBannerInfo = if (networkConnection.isOnline()) { - interactor.getDatesBannerInfo(courseId) + val courseDatesResult = if (networkConnection.isOnline()) { + interactor.getCourseDates(courseId) } else { - CourseDatesBannerInfo( - missedDeadlines = false, - missedGatedContent = false, - verifiedUpgradeLink = "", - contentTypeGatingEnabled = false, - hasEnded = false + CourseDatesResult( + datesSection = linkedMapOf(), + courseBanner = CourseDatesBannerInfo( + missedDeadlines = false, + missedGatedContent = false, + verifiedUpgradeLink = "", + contentTypeGatingEnabled = false, + hasEnded = false + ) ) } + val datesBannerInfo = courseDatesResult.courseBanner + + checkIfCalendarOutOfDate(courseDatesResult.datesSection.values.flatten()) + setBlocks(blocks) courseSubSections.clear() courseSubSectionUnit.clear() @@ -296,4 +307,16 @@ class CourseOutlineViewModel( analytics.verticalClickedEvent(courseId, courseTitle, blockId, blockName) } } -} \ No newline at end of file + + private fun checkIfCalendarOutOfDate(courseDates: List) { + viewModelScope.launch { + notifier.send( + CreateCalendarSyncEvent( + courseDates = courseDates, + dialogType = CalendarSyncDialogType.NONE.name, + checkOutOfSync = true, + ) + ) + } + } +} diff --git a/course/src/main/res/drawable/course_ic_calenday_sync.xml b/course/src/main/res/drawable/course_ic_calenday_sync.xml new file mode 100644 index 000000000..32a1bf361 --- /dev/null +++ b/course/src/main/res/drawable/course_ic_calenday_sync.xml @@ -0,0 +1,12 @@ + + + + diff --git a/course/src/main/res/layout/fragment_course_container.xml b/course/src/main/res/layout/fragment_course_container.xml index 878087ee0..9990fd80d 100644 --- a/course/src/main/res/layout/fragment_course_container.xml +++ b/course/src/main/res/layout/fragment_course_container.xml @@ -58,4 +58,13 @@ app:layout_constraintStart_toStartOf="parent" app:layout_constraintTop_toTopOf="parent" /> - \ No newline at end of file + + + diff --git a/course/src/main/res/values/strings.xml b/course/src/main/res/values/strings.xml index 9b22e4c95..63e1555de 100644 --- a/course/src/main/res/values/strings.xml +++ b/course/src/main/res/values/strings.xml @@ -55,6 +55,41 @@ Course dates are not currently available. + + Sync to calendar + Automatically sync all deadlines and due dates for this course to your calendar. + + \“%s\” Would Like to Access Your Calendar + %s would like to use your calendar list to subscribe to your personalized %s calendar for this course. + Don’t allow + + Add Course Dates to Calendar + Would you like to add \“%s\” dates to your calendar? \n\nYou can edit or remove your course dates at any time from your calendar or settings. + + Syncing calendar… + + \“%s\” has been added to your phone\'s calendar. + View Events + Done + + Remove Course Dates from Calendar + Would you like to remove the \“%s\” dates from your calendar? + Remove + + Your course calendar is out of date + Your course dates have been shifted and your course calendar is no longer up to date with your new schedule. + Update Now + Remove Course Calendar + + Your course calendar has been added. + Your course calendar has been removed. + Your course calendar has been updated. + Error Adding Calendar, Please try later + + Assignment Due + + + Header image for %1$s Play video 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 06c6ebb3d..a060753ab 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 @@ -1,26 +1,41 @@ package org.openedx.course.presentation.container import androidx.arch.core.executor.testing.InstantTaskExecutorRule -import org.openedx.core.system.connection.NetworkConnection -import org.openedx.core.R -import org.openedx.core.domain.model.CourseStructure -import org.openedx.core.domain.model.CoursewareAccess -import org.openedx.core.system.ResourceManager -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 io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.every +import io.mockk.mockk +import io.mockk.spyk import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.test.* +import kotlinx.coroutines.flow.emptyFlow +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.config.Config +import org.openedx.core.data.model.User +import org.openedx.core.data.storage.CorePreferences +import org.openedx.core.domain.model.AppConfig +import org.openedx.core.domain.model.CourseDatesCalendarSync +import org.openedx.core.domain.model.CourseStructure +import org.openedx.core.domain.model.CoursewareAccess +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.data.storage.CoursePreferences +import org.openedx.course.domain.interactor.CourseInteractor +import org.openedx.course.presentation.CourseAnalytics +import org.openedx.course.presentation.calendarsync.CalendarManager import java.net.UnknownHostException import java.util.Date @@ -35,13 +50,32 @@ class CourseContainerViewModelTest { private val resourceManager = mockk() private val config = mockk() private val interactor = mockk() + private val calendarManager = mockk() private val networkConnection = mockk() private val notifier = spyk() private val analytics = mockk() + private val corePreferences = mockk() + private val coursePreferences = mockk() + private val openEdx = "OpenEdx" + private val calendarTitle = "OpenEdx - Abc" private val noInternet = "Slow or no internet connection" private val somethingWrong = "Something went wrong" + private val user = User( + id = 0, + username = "", + email = "", + name = "", + ) + private val appConfig = AppConfig( + CourseDatesCalendarSync( + isEnabled = true, + isSelfPacedEnabled = true, + isInstructorPacedEnabled = true, + isDeepLinkEnabled = false, + ) + ) private val courseStructure = CourseStructure( root = "", blockData = listOf(), @@ -69,8 +103,13 @@ class CourseContainerViewModelTest { @Before fun setUp() { Dispatchers.setMain(dispatcher) + every { resourceManager.getString(id = R.string.platform_name) } returns openEdx every { resourceManager.getString(R.string.core_error_no_connection) } returns noInternet every { resourceManager.getString(R.string.core_error_unknown_error) } returns somethingWrong + every { corePreferences.user } returns user + every { corePreferences.appConfig } returns appConfig + every { notifier.notifier } returns emptyFlow() + every { calendarManager.getCourseCalendarTitle(any()) } returns calendarTitle } @After @@ -85,10 +124,13 @@ class CourseContainerViewModelTest { "", config, interactor, + calendarManager, resourceManager, notifier, networkConnection, - analytics + analytics, + corePreferences, + coursePreferences, ) every { networkConnection.isOnline() } returns true coEvery { interactor.preloadCourseStructure(any()) } throws UnknownHostException() @@ -110,10 +152,13 @@ class CourseContainerViewModelTest { "", config, interactor, + calendarManager, resourceManager, notifier, networkConnection, - analytics + analytics, + corePreferences, + coursePreferences, ) every { networkConnection.isOnline() } returns true coEvery { interactor.preloadCourseStructure(any()) } throws Exception() @@ -135,10 +180,13 @@ class CourseContainerViewModelTest { "", config, interactor, + calendarManager, resourceManager, notifier, networkConnection, - analytics + analytics, + corePreferences, + coursePreferences, ) every { networkConnection.isOnline() } returns true coEvery { interactor.preloadCourseStructure(any()) } returns Unit @@ -160,10 +208,13 @@ class CourseContainerViewModelTest { "", config, interactor, + calendarManager, resourceManager, notifier, networkConnection, - analytics + analytics, + corePreferences, + coursePreferences, ) every { networkConnection.isOnline() } returns false coEvery { interactor.preloadCourseStructureFromCache(any()) } returns Unit @@ -186,10 +237,13 @@ class CourseContainerViewModelTest { "", config, interactor, + calendarManager, resourceManager, notifier, networkConnection, - analytics + analytics, + corePreferences, + coursePreferences, ) coEvery { interactor.preloadCourseStructure(any()) } throws UnknownHostException() coEvery { notifier.send(CourseStructureUpdated("", false)) } returns Unit @@ -210,10 +264,13 @@ class CourseContainerViewModelTest { "", config, interactor, + calendarManager, resourceManager, notifier, networkConnection, - analytics + analytics, + corePreferences, + coursePreferences, ) coEvery { interactor.preloadCourseStructure(any()) } throws Exception() coEvery { notifier.send(CourseStructureUpdated("", false)) } returns Unit @@ -234,10 +291,13 @@ class CourseContainerViewModelTest { "", config, interactor, + calendarManager, resourceManager, notifier, networkConnection, - analytics + analytics, + corePreferences, + coursePreferences, ) coEvery { interactor.preloadCourseStructure(any()) } returns Unit coEvery { notifier.send(CourseStructureUpdated("", false)) } returns Unit @@ -250,4 +310,4 @@ class CourseContainerViewModelTest { assert(viewModel.showProgress.value == false) } -} \ No newline at end of file +} 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 index 6419ed2cd..df7becbc3 100644 --- a/course/src/test/java/org/openedx/course/presentation/dates/CourseDatesViewModelTest.kt +++ b/course/src/test/java/org/openedx/course/presentation/dates/CourseDatesViewModelTest.kt @@ -7,6 +7,7 @@ import io.mockk.every import io.mockk.mockk 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 @@ -21,15 +22,22 @@ 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.data.model.User +import org.openedx.core.data.storage.CorePreferences +import org.openedx.core.domain.model.AppConfig import org.openedx.core.domain.model.CourseDateBlock import org.openedx.core.domain.model.CourseDatesBannerInfo +import org.openedx.core.domain.model.CourseDatesCalendarSync 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.core.system.notifier.CalendarSyncEvent.CreateCalendarSyncEvent +import org.openedx.core.system.notifier.CourseNotifier import org.openedx.course.domain.interactor.CourseInteractor +import org.openedx.course.presentation.calendarsync.CalendarManager import java.net.UnknownHostException import java.util.Date @@ -41,12 +49,31 @@ class CourseDatesViewModelTest { private val dispatcher = StandardTestDispatcher() private val resourceManager = mockk() + private val notifier = mockk() private val interactor = mockk() + private val calendarManager = mockk() private val networkConnection = mockk() + private val corePreferences = mockk() + private val openEdx = "OpenEdx" + private val calendarTitle = "OpenEdx - Abc" private val noInternet = "Slow or no internet connection" private val somethingWrong = "Something went wrong" + private val user = User( + id = 0, + username = "", + email = "", + name = "", + ) + private val appConfig = AppConfig( + CourseDatesCalendarSync( + isEnabled = true, + isSelfPacedEnabled = true, + isInstructorPacedEnabled = true, + isDeepLinkEnabled = false, + ) + ) private val dateBlock = CourseDateBlock( complete = false, date = Date(), @@ -105,9 +132,15 @@ class CourseDatesViewModelTest { @Before fun setUp() { Dispatchers.setMain(dispatcher) + every { resourceManager.getString(id = R.string.platform_name) } returns openEdx 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 + every { corePreferences.user } returns user + every { corePreferences.appConfig } returns appConfig + every { notifier.notifier } returns emptyFlow() + every { calendarManager.getCourseCalendarTitle(any()) } returns calendarTitle + coEvery { notifier.send(any()) } returns Unit } @After @@ -117,7 +150,17 @@ class CourseDatesViewModelTest { @Test fun `getCourseDates no internet connection exception`() = runTest { - val viewModel = CourseDatesViewModel("", true, interactor, networkConnection, resourceManager) + val viewModel = CourseDatesViewModel( + "", + "", + true, + notifier, + interactor, + calendarManager, + networkConnection, + resourceManager, + corePreferences + ) every { networkConnection.isOnline() } returns true coEvery { interactor.getCourseDates(any()) } throws UnknownHostException() advanceUntilIdle() @@ -133,7 +176,17 @@ class CourseDatesViewModelTest { @Test fun `getCourseDates unknown exception`() = runTest { - val viewModel = CourseDatesViewModel("", true, interactor, networkConnection, resourceManager) + val viewModel = CourseDatesViewModel( + "", + "", + true, + notifier, + interactor, + calendarManager, + networkConnection, + resourceManager, + corePreferences + ) every { networkConnection.isOnline() } returns true coEvery { interactor.getCourseDates(any()) } throws Exception() advanceUntilIdle() @@ -149,7 +202,17 @@ class CourseDatesViewModelTest { @Test fun `getCourseDates success with internet`() = runTest { - val viewModel = CourseDatesViewModel("", true, interactor, networkConnection, resourceManager) + val viewModel = CourseDatesViewModel( + "", + "", + true, + notifier, + interactor, + calendarManager, + networkConnection, + resourceManager, + corePreferences + ) every { networkConnection.isOnline() } returns true coEvery { interactor.getCourseDates(any()) } returns mockedCourseDatesResult @@ -164,7 +227,17 @@ class CourseDatesViewModelTest { @Test fun `getCourseDates success with EmptyList`() = runTest { - val viewModel = CourseDatesViewModel("", true, interactor, networkConnection, resourceManager) + val viewModel = CourseDatesViewModel( + "", + "", + true, + notifier, + interactor, + calendarManager, + networkConnection, + resourceManager, + corePreferences + ) every { networkConnection.isOnline() } returns true coEvery { interactor.getCourseDates(any()) } returns CourseDatesResult( datesSection = linkedMapOf(), 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 6f129106c..6683b9f3d 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,12 +4,21 @@ import androidx.arch.core.executor.testing.InstantTaskExecutorRule import androidx.lifecycle.Lifecycle import androidx.lifecycle.LifecycleOwner import androidx.lifecycle.LifecycleRegistry -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.flow.flow -import kotlinx.coroutines.test.* +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.assertEquals import org.junit.Before @@ -20,10 +29,23 @@ 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.model.DateType import org.openedx.core.data.storage.CorePreferences -import org.openedx.core.domain.model.* +import org.openedx.core.domain.model.Block +import org.openedx.core.domain.model.BlockCounts +import org.openedx.core.domain.model.CourseComponentStatus +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.module.DownloadWorkerController -import org.openedx.core.module.db.* +import org.openedx.core.module.db.DownloadDao +import org.openedx.core.module.db.DownloadModel +import org.openedx.core.module.db.DownloadModelEntity +import org.openedx.core.module.db.DownloadedState +import org.openedx.core.module.db.FileType import org.openedx.core.system.ResourceManager import org.openedx.core.system.connection.NetworkConnection import org.openedx.core.system.notifier.CourseNotifier @@ -31,7 +53,7 @@ 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.* +import java.util.Date @OptIn(ExperimentalCoroutinesApi::class) class CourseOutlineViewModelTest { @@ -106,7 +128,7 @@ class CourseOutlineViewModelTest { ) ) - val courseStructure = CourseStructure( + private val courseStructure = CourseStructure( root = "", blockData = blocks, id = "id", @@ -130,6 +152,26 @@ class CourseOutlineViewModelTest { isSelfPaced = false ) + 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, @@ -137,6 +179,10 @@ class CourseOutlineViewModelTest { contentTypeGatingEnabled = false, hasEnded = true, ) + private val mockedCourseDatesResult = CourseDatesResult( + datesSection = mockDateBlocks, + courseBanner = mockCourseDatesBannerInfo, + ) private val downloadModel = DownloadModel( "id", @@ -156,6 +202,8 @@ class CourseOutlineViewModelTest { 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" + + coEvery { interactor.getCourseDates(any()) } returns mockedCourseDatesResult } @After @@ -237,7 +285,6 @@ class CourseOutlineViewModelTest { } coEvery { interactor.getCourseStatus(any()) } returns CourseComponentStatus("id") every { config.isCourseNestedListEnabled() } returns false - coEvery { interactor.getDatesBannerInfo(any()) } returns mockCourseDatesBannerInfo val viewModel = CourseOutlineViewModel( "", @@ -316,7 +363,6 @@ class CourseOutlineViewModelTest { } coEvery { interactor.getCourseStatus(any()) } returns CourseComponentStatus("id") every { config.isCourseNestedListEnabled() } returns false - coEvery { interactor.getDatesBannerInfo(any()) } returns mockCourseDatesBannerInfo val viewModel = CourseOutlineViewModel( "", @@ -389,7 +435,6 @@ class CourseOutlineViewModelTest { 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( "", @@ -421,7 +466,6 @@ class CourseOutlineViewModelTest { coEvery { workerController.saveModels(any()) } returns Unit coEvery { downloadDao.readAllData() } returns flow { emit(emptyList()) } every { config.isCourseNestedListEnabled() } returns false - coEvery { interactor.getDatesBannerInfo(any()) } returns mockCourseDatesBannerInfo val viewModel = CourseOutlineViewModel( "", 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 72cb9f380..c85390fa1 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 @@ -18,8 +18,11 @@ class DashboardRepository( username = user?.username ?: "", page = page ) + preferencesManager.appConfig = result.configs.mapToDomain() + if (page == 1) dao.clearCachedData() - dao.insertEnrolledCourseEntity(*result.enrollments.results.map { it.mapToRoomEntity() }.toTypedArray()) + dao.insertEnrolledCourseEntity(*result.enrollments.results.map { it.mapToRoomEntity() } + .toTypedArray()) return result.enrollments.mapToDomain() } @@ -27,4 +30,4 @@ class DashboardRepository( val list = dao.readAllData() return list.map { it.mapToDomain() } } -} \ No newline at end of file +} From 77fc6cbed2ddea05780c05a641edd942ad68c515 Mon Sep 17 00:00:00 2001 From: Kirill Izmaylov Date: Tue, 27 Feb 2024 13:27:30 +0300 Subject: [PATCH 12/39] feat: eula on registration and sign in (#236) --- .../main/java/org/openedx/app/di/AppModule.kt | 2 + .../java/org/openedx/app/di/ScreenModule.kt | 5 +- .../auth/presentation/AgreementProvider.kt | 44 ++++++++ .../openedx/auth/presentation/AuthRouter.kt | 2 + .../presentation/signin/SignInFragment.kt | 38 ++----- .../auth/presentation/signin/SignInUIState.kt | 3 + .../presentation/signin/SignInViewModel.kt | 44 +++++++- .../presentation/signin/compose/SignInView.kt | 17 ++- .../presentation/signup/SignUpFragment.kt | 3 + .../auth/presentation/signup/SignUpUIState.kt | 3 + .../presentation/signup/SignUpViewModel.kt | 103 ++++++++++++------ .../presentation/signup/compose/SignUpView.kt | 85 ++++++++++----- .../openedx/auth/presentation/ui/AuthUI.kt | 14 ++- .../auth/presentation/ui/CheckboxField.kt | 62 +++++++++++ auth/src/main/res/values/strings.xml | 6 + .../signin/SignInViewModelTest.kt | 31 ++++++ .../signup/SignUpViewModelTest.kt | 29 ++++- .../java/org/openedx/core/ApiConstants.kt | 6 +- .../java/org/openedx/core/config/Config.kt | 5 + .../core/domain/model/RegistrationField.kt | 26 ++++- .../openedx/core/extension/TextConverter.kt | 9 +- .../java/org/openedx/core/ui/ComposeCommon.kt | 3 +- 22 files changed, 427 insertions(+), 113 deletions(-) create mode 100644 auth/src/main/java/org/openedx/auth/presentation/AgreementProvider.kt create mode 100644 auth/src/main/java/org/openedx/auth/presentation/ui/CheckboxField.kt 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 403f50d0c..f798d8559 100644 --- a/app/src/main/java/org/openedx/app/di/AppModule.kt +++ b/app/src/main/java/org/openedx/app/di/AppModule.kt @@ -18,6 +18,7 @@ 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.AgreementProvider import org.openedx.auth.presentation.AuthAnalytics import org.openedx.auth.presentation.AuthRouter import org.openedx.auth.presentation.sso.FacebookAuthHelper @@ -168,6 +169,7 @@ val appModule = module { single { get() } single { get() } + factory { AgreementProvider(get(), get()) } factory { FacebookAuthHelper() } factory { GoogleAuthHelper(get()) } factory { MicrosoftAuthHelper() } 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 a69c5f01e..c74d007a5 100644 --- a/app/src/main/java/org/openedx/app/di/ScreenModule.kt +++ b/app/src/main/java/org/openedx/app/di/ScreenModule.kt @@ -75,13 +75,16 @@ val screenModule = module { get(), get(), get(), + get(), + get(), + get(), courseId, infoType, ) } viewModel { (courseId: String?, infoType: String?) -> - SignUpViewModel(get(), get(), get(), get(), get(), get(), get(), courseId, infoType) + SignUpViewModel(get(), get(), get(), get(), get(), get(), get(), get(), get(), courseId, infoType) } viewModel { RestorePasswordViewModel(get(), get(), get(), get()) } diff --git a/auth/src/main/java/org/openedx/auth/presentation/AgreementProvider.kt b/auth/src/main/java/org/openedx/auth/presentation/AgreementProvider.kt new file mode 100644 index 000000000..0141df227 --- /dev/null +++ b/auth/src/main/java/org/openedx/auth/presentation/AgreementProvider.kt @@ -0,0 +1,44 @@ +package org.openedx.auth.presentation + +import androidx.compose.ui.text.intl.Locale +import org.openedx.auth.R +import org.openedx.core.config.Config +import org.openedx.core.system.ResourceManager + +class AgreementProvider( + private val config: Config, + private val resourceManager: ResourceManager, +) { + internal fun getAgreement(isSignIn: Boolean): String? { + val agreementConfig = config.getAgreement(Locale.current.language) + if (agreementConfig.eulaUrl.isBlank()) return null + val platformName = config.getPlatformName() + val agreementRes = if (isSignIn) { + R.string.auth_agreement_signin_in + } else { + R.string.auth_agreement_creating_account + } + val eula = resourceManager.getString( + R.string.auth_cdata_template, + agreementConfig.eulaUrl, + "$platformName ${resourceManager.getString(R.string.auth_agreement_eula)}" + ) + val tos = resourceManager.getString( + R.string.auth_cdata_template, + agreementConfig.tosUrl, + "$platformName ${resourceManager.getString(R.string.auth_agreement_tos)}" + ) + val privacy = resourceManager.getString( + R.string.auth_cdata_template, + agreementConfig.privacyPolicyUrl, + "$platformName ${resourceManager.getString(R.string.auth_agreement_privacy)}" + ) + return resourceManager.getString( + agreementRes, + eula, + tos, + config.getPlatformName(), + privacy, + ) + } +} 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 a9a8357b7..9b1266119 100644 --- a/auth/src/main/java/org/openedx/auth/presentation/AuthRouter.kt +++ b/auth/src/main/java/org/openedx/auth/presentation/AuthRouter.kt @@ -20,5 +20,7 @@ interface AuthRouter { fun navigateToNativeDiscoverCourses(fm: FragmentManager, querySearch: String) + fun navigateToWebContent(fm: FragmentManager, title: String, url: String) + fun clearBackStack(fm: FragmentManager) } 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 fb613125f..fabd8a40b 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 @@ -11,14 +11,11 @@ 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.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 @@ -31,8 +28,6 @@ class SignInFragment : Fragment() { requireArguments().getString(ARG_INFO_TYPE, "") ) } - private val router: AuthRouter by inject() - private val whatsNewGlobalManager by inject() override fun onCreateView( inflater: LayoutInflater, @@ -61,41 +56,27 @@ class SignInFragment : Fragment() { ) AuthEvent.ForgotPasswordClick -> { - viewModel.forgotPasswordClickedEvent() - router.navigateToRestorePassword(parentFragmentManager) + viewModel.navigateToForgotPassword(parentFragmentManager) } AuthEvent.RegisterClick -> { - viewModel.signUpClickedEvent() - router.navigateToSignUp(parentFragmentManager, null, null) + viewModel.navigateToSignUp(parentFragmentManager) } AuthEvent.BackClick -> { requireActivity().supportFragmentManager.popBackStackImmediate() } + + is AuthEvent.OpenLink -> viewModel.openLink( + parentFragmentManager, + event.links, + event.link + ) } }, ) LaunchedEffect(state.loginSuccess) { - val isNeedToShowWhatsNew = - whatsNewGlobalManager.shouldShowWhatsNew() - if (state.loginSuccess) { - router.clearBackStack(parentFragmentManager) - if (isNeedToShowWhatsNew) { - router.navigateToWhatsNew( - parentFragmentManager, - viewModel.courseId, - viewModel.infoType - ) - } else { - router.navigateToMain( - parentFragmentManager, - viewModel.courseId, - viewModel.infoType - ) - } - } - + viewModel.proceedWhatsNew(parentFragmentManager) } } else { AppUpgradeRequiredScreen( @@ -125,6 +106,7 @@ class SignInFragment : Fragment() { internal sealed interface AuthEvent { data class SignIn(val login: String, val password: String) : AuthEvent data class SocialSignIn(val authType: AuthType) : AuthEvent + data class OpenLink(val links: Map, val link: String) : AuthEvent object RegisterClick : AuthEvent object ForgotPasswordClick : AuthEvent object BackClick : AuthEvent 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 index 829d376f1..9ce5cfc98 100644 --- a/auth/src/main/java/org/openedx/auth/presentation/signin/SignInUIState.kt +++ b/auth/src/main/java/org/openedx/auth/presentation/signin/SignInUIState.kt @@ -1,5 +1,7 @@ package org.openedx.auth.presentation.signin +import org.openedx.core.domain.model.RegistrationField + /** * Data class to store UI state of the SignIn screen * @@ -18,4 +20,5 @@ internal data class SignInUIState( val isLogistrationEnabled: Boolean = false, val showProgress: Boolean = false, val loginSuccess: Boolean = false, + val agreement: RegistrationField? = null, ) 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 d47950341..cf1c6afa5 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,6 +1,7 @@ package org.openedx.auth.presentation.signin import androidx.fragment.app.Fragment +import androidx.fragment.app.FragmentManager import androidx.lifecycle.LiveData import androidx.lifecycle.MutableLiveData import androidx.lifecycle.viewModelScope @@ -14,7 +15,9 @@ 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.AgreementProvider import org.openedx.auth.presentation.AuthAnalytics +import org.openedx.auth.presentation.AuthRouter import org.openedx.auth.presentation.sso.OAuthHelper import org.openedx.core.BaseViewModel import org.openedx.core.SingleEventLiveData @@ -22,7 +25,9 @@ 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.domain.model.createHonorCodeField import org.openedx.core.extension.isInternetError +import org.openedx.core.presentation.global.WhatsNewGlobalManager import org.openedx.core.system.EdxError import org.openedx.core.system.ResourceManager import org.openedx.core.system.notifier.AppUpgradeEvent @@ -38,6 +43,9 @@ class SignInViewModel( private val appUpgradeNotifier: AppUpgradeNotifier, private val analytics: AuthAnalytics, private val oAuthHelper: OAuthHelper, + private val router: AuthRouter, + private val whatsNewGlobalManager: WhatsNewGlobalManager, + agreementProvider: AgreementProvider, config: Config, val courseId: String?, val infoType: String?, @@ -52,6 +60,7 @@ class SignInViewModel( isMicrosoftAuthEnabled = config.getMicrosoftConfig().isEnabled(), isSocialAuthEnabled = config.isSocialAuthEnabled(), isLogistrationEnabled = config.isPreLoginExperienceEnabled(), + agreement = agreementProvider.getAgreement(isSignIn = true)?.createHonorCodeField(), ) ) internal val uiState: StateFlow = _uiState @@ -124,11 +133,13 @@ class SignInViewModel( } } - fun signUpClickedEvent() { + fun navigateToSignUp(parentFragmentManager: FragmentManager) { + router.navigateToSignUp(parentFragmentManager, null, null) analytics.signUpClickedEvent() } - fun forgotPasswordClickedEvent() { + fun navigateToForgotPassword(parentFragmentManager: FragmentManager) { + router.navigateToRestorePassword(parentFragmentManager) analytics.forgotPasswordClickedEvent() } @@ -177,4 +188,33 @@ class SignInViewModel( } } ?: onUnknownError() } + + fun openLink(fragmentManager: FragmentManager, links: Map, link: String) { + links.forEach { (key, value) -> + if (value == link) { + router.navigateToWebContent(fragmentManager, key, value) + return + } + } + } + + fun proceedWhatsNew(parentFragmentManager: FragmentManager) { + val isNeedToShowWhatsNew = whatsNewGlobalManager.shouldShowWhatsNew() + if (uiState.value.loginSuccess) { + router.clearBackStack(parentFragmentManager) + if (isNeedToShowWhatsNew) { + router.navigateToWhatsNew( + parentFragmentManager, + courseId, + infoType + ) + } else { + router.navigateToMain( + parentFragmentManager, + courseId, + infoType + ) + } + } + } } diff --git a/auth/src/main/java/org/openedx/auth/presentation/signin/compose/SignInView.kt b/auth/src/main/java/org/openedx/auth/presentation/signin/compose/SignInView.kt index 0abcf0bf9..c40884d6f 100644 --- a/auth/src/main/java/org/openedx/auth/presentation/signin/compose/SignInView.kt +++ b/auth/src/main/java/org/openedx/auth/presentation/signin/compose/SignInView.kt @@ -59,8 +59,10 @@ 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.extension.TextConverter import org.openedx.core.ui.BackBtn import org.openedx.core.ui.HandleUIMessage +import org.openedx.core.ui.HyperlinkText import org.openedx.core.ui.OpenEdXButton import org.openedx.core.ui.WindowSize import org.openedx.core.ui.WindowType @@ -185,6 +187,20 @@ internal fun LoginScreen( state, onEvent, ) + state.agreement?.let { + Spacer(modifier = Modifier.height(24.dp)) + val linkedText = + TextConverter.htmlTextToLinkedText(state.agreement.label) + HyperlinkText( + modifier = Modifier.testTag("txt_${state.agreement.name}"), + fullText = linkedText.text, + hyperLinks = linkedText.links, + linkTextColor = MaterialTheme.appColors.primary, + action = { link -> + onEvent(AuthEvent.OpenLink(linkedText.links, link)) + }, + ) + } } } } @@ -349,7 +365,6 @@ private fun SignInScreenPreview() { } } - @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 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 2408202df..fa27d7d60 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 @@ -70,6 +70,9 @@ class SignUpFragment : Fragment() { }, onFieldUpdated = { key, value -> viewModel.updateField(key, value) + }, + onHyperLinkClick = { links, link -> + viewModel.openLink(parentFragmentManager, links, link) } ) 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 23e0458d9..0f7873b78 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 @@ -6,6 +6,9 @@ import org.openedx.core.system.notifier.AppUpgradeEvent data class SignUpUIState( val allFields: List = emptyList(), + val requiredFields: List = emptyList(), + val optionalFields: List = emptyList(), + val agreementFields: List = emptyList(), val isFacebookAuthEnabled: Boolean = false, val isGoogleAuthEnabled: Boolean = false, val isMicrosoftAuthEnabled: Boolean = false, 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 e8ca67e93..2b7cdc09f 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,6 +1,7 @@ package org.openedx.auth.presentation.signup import androidx.fragment.app.Fragment +import androidx.fragment.app.FragmentManager import androidx.lifecycle.viewModelScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.channels.BufferOverflow @@ -14,20 +15,23 @@ 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.AgreementProvider import org.openedx.auth.presentation.AuthAnalytics +import org.openedx.auth.presentation.AuthRouter 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.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.domain.model.createHonorCodeField import org.openedx.core.extension.isInternetError import org.openedx.core.system.ResourceManager import org.openedx.core.system.notifier.AppUpgradeNotifier import org.openedx.core.utils.Logger +import org.openedx.core.R as coreR class SignUpViewModel( private val interactor: AuthInteractor, @@ -35,8 +39,10 @@ class SignUpViewModel( private val analytics: AuthAnalytics, private val preferencesManager: CorePreferences, private val appUpgradeNotifier: AppUpgradeNotifier, + private val agreementProvider: AgreementProvider, private val oAuthHelper: OAuthHelper, private val config: Config, + private val router: AuthRouter, val courseId: String?, val infoType: String?, ) : BaseViewModel() { @@ -69,35 +75,63 @@ class SignUpViewModel( _uiState.update { it.copy(isLoading = true) } viewModelScope.launch { try { - val allFields = interactor.getRegistrationFields() - _uiState.update { state -> - state.copy( - allFields = allFields, - isLoading = false, - ) - } + updateFields(interactor.getRegistrationFields()) } catch (e: Exception) { if (e.isInternetError()) { _uiMessage.emit( UIMessage.SnackBarMessage( - resourceManager.getString(R.string.core_error_no_connection) + resourceManager.getString(coreR.string.core_error_no_connection) ) ) } else { _uiMessage.emit( UIMessage.SnackBarMessage( - resourceManager.getString(R.string.core_error_unknown_error) + resourceManager.getString(coreR.string.core_error_unknown_error) ) ) } + } finally { + _uiState.update { state -> + state.copy(isLoading = false) + } } } } + private fun updateFields(allFields: List) { + val mutableAllFields = allFields.toMutableList() + val requiredFields = mutableListOf() + val optionalFields = mutableListOf() + val agreementFields = mutableListOf() + val agreementText = agreementProvider.getAgreement(isSignIn = false) + if (agreementText != null) { + val honourCode = allFields.find { it.name == ApiConstants.RegistrationFields.HONOR_CODE } + val marketingEmails = allFields.find { it.name == ApiConstants.RegistrationFields.MARKETING_EMAILS } + mutableAllFields.remove(honourCode) + requiredFields.addAll(mutableAllFields.filter { it.required }) + optionalFields.addAll(mutableAllFields.filter { !it.required }) + requiredFields.remove(marketingEmails) + optionalFields.remove(marketingEmails) + marketingEmails?.let { agreementFields.add(it) } + agreementFields.add(agreementText.createHonorCodeField()) + } else { + requiredFields.addAll(mutableAllFields.filter { it.required }) + optionalFields.addAll(mutableAllFields.filter { !it.required }) + } + _uiState.update { state -> + state.copy( + allFields = mutableAllFields, + requiredFields = requiredFields, + optionalFields = optionalFields, + agreementFields = agreementFields, + ) + } + } + fun register() { analytics.createAccountClickedEvent("") val mapFields = uiState.value.allFields.associate { it.name to it.placeholder } + - mapOf(ApiConstants.HONOR_CODE to true.toString()) + mapOf(ApiConstants.RegistrationFields.HONOR_CODE to true.toString()) val resultMap = mapFields.toMutableMap() uiState.value.allFields.filter { !it.required }.forEach { (k, _) -> if (mapFields[k].isNullOrEmpty()) { @@ -137,13 +171,13 @@ class SignUpViewModel( if (e.isInternetError()) { _uiMessage.emit( UIMessage.SnackBarMessage( - resourceManager.getString(R.string.core_error_no_connection) + resourceManager.getString(coreR.string.core_error_no_connection) ) ) } else { _uiMessage.emit( UIMessage.SnackBarMessage( - resourceManager.getString(R.string.core_error_unknown_error) + resourceManager.getString(coreR.string.core_error_unknown_error) ) ) } @@ -178,18 +212,18 @@ class SignUpViewModel( runCatching { interactor.loginSocial(socialAuth.accessToken, socialAuth.authType) }.onFailure { + val fields = uiState.value.allFields.toMutableList() + .filter { field -> field.type != RegistrationFieldType.PASSWORD } + updateField(ApiConstants.NAME, socialAuth.name) + updateField(ApiConstants.EMAIL, socialAuth.email) + setErrorInstructions(emptyMap()) _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 ) } + updateFields(fields) }.onSuccess { setUserId() analytics.userLoginEvent(socialAuth.authType.methodName) @@ -208,12 +242,8 @@ class SignUpViewModel( updatedFields.add(it.copy(errorInstructions = "")) } } - _uiState.update { state -> - state.copy( - allFields = updatedFields, - isLoading = false, - ) - } + updateFields(updatedFields) + _uiState.update { it.copy(isLoading = false) } } private fun collectAppUpgradeEvent() { @@ -231,15 +261,22 @@ 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 - } + val updatedFields = uiState.value.allFields.toMutableList().map { field -> + if (field.name == key) { + field.copy(placeholder = value) + } else { + field + } + } + updateFields(updatedFields) + } + + fun openLink(fragmentManager: FragmentManager, links: Map, link: String) { + links.forEach { (key, value) -> + if (value == link) { + router.navigateToWebContent(fragmentManager, key, value) + return } - it.copy(allFields = updatedFields) } } } diff --git a/auth/src/main/java/org/openedx/auth/presentation/signup/compose/SignUpView.kt b/auth/src/main/java/org/openedx/auth/presentation/signup/compose/SignUpView.kt index 0658396e2..2852ab5fe 100644 --- a/auth/src/main/java/org/openedx/auth/presentation/signup/compose/SignUpView.kt +++ b/auth/src/main/java/org/openedx/auth/presentation/signup/compose/SignUpView.kt @@ -97,6 +97,7 @@ internal fun SignUpView( onBackClick: () -> Unit, onFieldUpdated: (String, String) -> Unit, onRegisterClick: (authType: AuthType) -> Unit, + onHyperLinkClick: (Map, String) -> Unit, ) { val scaffoldState = rememberScaffoldState() val focusManager = LocalFocusManager.current @@ -137,9 +138,6 @@ internal fun SignUpView( 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 { @@ -294,7 +292,6 @@ internal fun SignUpView( modifier = Modifier .fillMaxHeight() .background(MaterialTheme.appColors.background), - verticalArrangement = Arrangement.spacedBy(24.dp), horizontalAlignment = Alignment.CenterHorizontally ) { if (uiState.isLoading) { @@ -350,7 +347,7 @@ internal fun SignUpView( } } RequiredFields( - fields = fields, + fields = uiState.requiredFields, showErrorMap = showErrorMap, selectableNamesMap = selectableNamesMap, onSelectClick = { serverName, field, list -> @@ -369,7 +366,7 @@ internal fun SignUpView( }, onFieldUpdated = onFieldUpdated ) - if (optionalFields.isNotEmpty()) { + if (uiState.optionalFields.isNotEmpty()) { ExpandableText( modifier = Modifier.testTag("txt_optional_field"), isExpanded = showOptionalFields, @@ -377,32 +374,55 @@ internal fun SignUpView( 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() - } + AnimatedVisibility(visible = showOptionalFields) { + OptionalFields( + fields = uiState.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, - ) - } + } + }, + onFieldUpdated = onFieldUpdated, + ) } } + if (uiState.agreementFields.isNotEmpty()) { + OptionalFields( + fields = uiState.agreementFields, + 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, + hyperLinkAction = { links, link -> + onHyperLinkClick(links, link) + }, + ) + } if (uiState.isButtonLoading) { Box( @@ -460,6 +480,7 @@ private fun RegistrationScreenPreview() { onBackClick = {}, onRegisterClick = {}, onFieldUpdated = { _, _ -> }, + onHyperLinkClick = { _, _ -> }, ) } } @@ -472,12 +493,16 @@ private fun RegistrationScreenTabletPreview() { SignUpView( windowSize = WindowSize(WindowType.Medium, WindowType.Medium), uiState = SignUpUIState( - allFields = listOf(field, field, field.copy(required = false)), + allFields = listOf(field), + requiredFields = listOf(field, field), + optionalFields = listOf(field, field), + agreementFields = listOf(field), ), uiMessage = null, onBackClick = {}, onRegisterClick = {}, onFieldUpdated = { _, _ -> }, + onHyperLinkClick = { _, _ -> }, ) } } 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 a16e77505..4f98ea50c 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 @@ -95,7 +95,9 @@ fun RequiredFields( } RegistrationFieldType.CHECKBOX -> { - //Text("checkbox") + CheckboxField(text = field.label, defaultValue = field.defaultValue) { + onFieldUpdated(field.name, it.toString()) + } } RegistrationFieldType.SELECT -> { @@ -139,6 +141,7 @@ fun OptionalFields( selectableNamesMap: MutableMap, onSelectClick: (String, RegistrationField, List) -> Unit, onFieldUpdated: (String, String) -> Unit, + hyperLinkAction: ((Map, String) -> Unit)? = null, ) { Column { fields.forEach { field -> @@ -167,12 +170,17 @@ fun OptionalFields( HyperlinkText( fullText = linkedText.text, hyperLinks = linkedText.links, - linkTextColor = MaterialTheme.appColors.primary + linkTextColor = MaterialTheme.appColors.primary, + action = { + hyperLinkAction?.invoke(linkedText.links, it) + }, ) } RegistrationFieldType.CHECKBOX -> { - //Text("checkbox") + CheckboxField(text = field.label, defaultValue = field.defaultValue) { + onFieldUpdated(field.name, it.toString()) + } } RegistrationFieldType.SELECT -> { diff --git a/auth/src/main/java/org/openedx/auth/presentation/ui/CheckboxField.kt b/auth/src/main/java/org/openedx/auth/presentation/ui/CheckboxField.kt new file mode 100644 index 000000000..b134cb59a --- /dev/null +++ b/auth/src/main/java/org/openedx/auth/presentation/ui/CheckboxField.kt @@ -0,0 +1,62 @@ +package org.openedx.auth.presentation.ui + +import android.content.res.Configuration +import androidx.compose.foundation.layout.Row +import androidx.compose.material.Checkbox +import androidx.compose.material.CheckboxDefaults +import androidx.compose.material.MaterialTheme +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +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.tooling.preview.Preview +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 + +@Composable +internal fun CheckboxField( + text: String, + defaultValue: Boolean, + onValueChanged: (Boolean) -> Unit +) { + var checkedState by remember { mutableStateOf(defaultValue) } + Row(verticalAlignment = Alignment.CenterVertically) { + Checkbox( + checked = checkedState, + colors = CheckboxDefaults.colors( + checkedColor = MaterialTheme.appColors.primary, + uncheckedColor = MaterialTheme.appColors.textFieldText + ), + onCheckedChange = { + checkedState = it + onValueChanged(it) + } + ) + Text( + modifier = Modifier.noRippleClickable { + checkedState = !checkedState + onValueChanged(checkedState) + }, + text = text, + style = MaterialTheme.appTypography.bodySmall, + ) + } +} + +@Preview(widthDp = 375, uiMode = Configuration.UI_MODE_NIGHT_NO) +@Preview(widthDp = 375, uiMode = Configuration.UI_MODE_NIGHT_YES) +@Composable +private fun CheckboxFieldPreview() { + OpenEdXTheme { + CheckboxField( + text = "Test", + defaultValue = true, + ) {} + } +} diff --git a/auth/src/main/res/values/strings.xml b/auth/src/main/res/values/strings.xml index 85eb3a47f..4f8ce12d8 100644 --- a/auth/src/main/res/values/strings.xml +++ b/auth/src/main/res/values/strings.xml @@ -33,4 +33,10 @@ Continue with Microsoft You\'ve successfully signed in with %s. We just need a little more information before you start learning with %s. + End User Licence Agreement + Terms of Service and Honor Code + Privacy Policy + By creating an account, you agree to the %1$s and %2$s and you acknowledge that %3$s and each Member process your personal data in accordance with the %4$s. + By signing in to this app, you agree to the %1$s and %2$s and you acknowledge that %3$s and each Member process your personal data in accordance with the %4$s. + %2$s]]> 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 6b04b596b..98278be4d 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 @@ -23,7 +23,9 @@ 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.AgreementProvider import org.openedx.auth.presentation.AuthAnalytics +import org.openedx.auth.presentation.AuthRouter import org.openedx.auth.presentation.sso.OAuthHelper import org.openedx.core.UIMessage import org.openedx.core.Validator @@ -33,6 +35,7 @@ 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.presentation.global.WhatsNewGlobalManager import org.openedx.core.system.EdxError import org.openedx.core.system.ResourceManager import org.openedx.core.system.notifier.AppUpgradeNotifier @@ -54,7 +57,10 @@ class SignInViewModelTest { private val interactor = mockk() private val analytics = mockk() private val appUpgradeNotifier = mockk() + private val agreementProvider = mockk() private val oAuthHelper = mockk() + private val router = mockk() + private val whatsNewGlobalManager = mockk() private val invalidCredential = "Invalid credentials" private val noInternet = "Slow or no internet connection" @@ -73,6 +79,7 @@ class SignInViewModelTest { 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 { agreementProvider.getAgreement(true) } returns null every { config.isPreLoginExperienceEnabled() } returns false every { config.isSocialAuthEnabled() } returns false every { config.getFacebookConfig() } returns FacebookConfig() @@ -98,7 +105,10 @@ class SignInViewModelTest { analytics = analytics, appUpgradeNotifier = appUpgradeNotifier, oAuthHelper = oAuthHelper, + agreementProvider = agreementProvider, config = config, + router = router, + whatsNewGlobalManager = whatsNewGlobalManager, courseId = "", infoType = "", ) @@ -126,7 +136,10 @@ class SignInViewModelTest { analytics = analytics, appUpgradeNotifier = appUpgradeNotifier, oAuthHelper = oAuthHelper, + agreementProvider = agreementProvider, config = config, + router = router, + whatsNewGlobalManager = whatsNewGlobalManager, courseId = "", infoType = "", ) @@ -156,7 +169,10 @@ class SignInViewModelTest { analytics = analytics, appUpgradeNotifier = appUpgradeNotifier, oAuthHelper = oAuthHelper, + agreementProvider = agreementProvider, config = config, + router = router, + whatsNewGlobalManager = whatsNewGlobalManager, courseId = "", infoType = "", ) @@ -185,7 +201,10 @@ class SignInViewModelTest { analytics = analytics, appUpgradeNotifier = appUpgradeNotifier, oAuthHelper = oAuthHelper, + agreementProvider = agreementProvider, config = config, + router = router, + whatsNewGlobalManager = whatsNewGlobalManager, courseId = "", infoType = "", ) @@ -216,7 +235,10 @@ class SignInViewModelTest { analytics = analytics, appUpgradeNotifier = appUpgradeNotifier, oAuthHelper = oAuthHelper, + agreementProvider = agreementProvider, config = config, + router = router, + whatsNewGlobalManager = whatsNewGlobalManager, courseId = "", infoType = "", ) @@ -248,7 +270,10 @@ class SignInViewModelTest { analytics = analytics, appUpgradeNotifier = appUpgradeNotifier, oAuthHelper = oAuthHelper, + agreementProvider = agreementProvider, config = config, + router = router, + whatsNewGlobalManager = whatsNewGlobalManager, courseId = "", infoType = "", ) @@ -281,7 +306,10 @@ class SignInViewModelTest { analytics = analytics, appUpgradeNotifier = appUpgradeNotifier, oAuthHelper = oAuthHelper, + agreementProvider = agreementProvider, config = config, + router = router, + whatsNewGlobalManager = whatsNewGlobalManager, courseId = "", infoType = "", ) @@ -314,7 +342,10 @@ class SignInViewModelTest { analytics = analytics, appUpgradeNotifier = appUpgradeNotifier, oAuthHelper = oAuthHelper, + agreementProvider = agreementProvider, config = config, + router = router, + whatsNewGlobalManager = whatsNewGlobalManager, courseId = "", infoType = "", ) 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 f93447bb8..448889b5b 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,6 +1,7 @@ package org.openedx.auth.presentation.signup import androidx.arch.core.executor.testing.InstantTaskExecutorRule +import androidx.compose.ui.text.intl.Locale import io.mockk.coEvery import io.mockk.coVerify import io.mockk.every @@ -26,7 +27,9 @@ 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.AgreementProvider import org.openedx.auth.presentation.AuthAnalytics +import org.openedx.auth.presentation.AuthRouter import org.openedx.auth.presentation.sso.OAuthHelper import org.openedx.core.ApiConstants import org.openedx.core.R @@ -37,13 +40,13 @@ 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.AgreementUrls import org.openedx.core.domain.model.RegistrationField import org.openedx.core.domain.model.RegistrationFieldType import org.openedx.core.system.ResourceManager import org.openedx.core.system.notifier.AppUpgradeNotifier import java.net.UnknownHostException - @ExperimentalCoroutinesApi class SignUpViewModelTest { @@ -57,7 +60,9 @@ class SignUpViewModelTest { private val interactor = mockk() private val analytics = mockk() private val appUpgradeNotifier = mockk() + private val agreementProvider = mockk() private val oAuthHelper = mockk() + private val router = mockk() //region parameters @@ -107,10 +112,13 @@ class SignUpViewModelTest { 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 { agreementProvider.getAgreement(false) } returns null every { config.isSocialAuthEnabled() } returns false + every { config.getAgreement(Locale.current.language) } returns AgreementUrls() every { config.getFacebookConfig() } returns FacebookConfig() every { config.getGoogleConfig() } returns GoogleConfig() every { config.getMicrosoftConfig() } returns MicrosoftConfig() + every { config.getMicrosoftConfig() } returns MicrosoftConfig() } @After @@ -127,7 +135,9 @@ class SignUpViewModelTest { preferencesManager = preferencesManager, appUpgradeNotifier = appUpgradeNotifier, oAuthHelper = oAuthHelper, + agreementProvider = agreementProvider, config = config, + router = router, courseId = "", infoType = "", ) @@ -168,7 +178,9 @@ class SignUpViewModelTest { preferencesManager = preferencesManager, appUpgradeNotifier = appUpgradeNotifier, oAuthHelper = oAuthHelper, + agreementProvider = agreementProvider, config = config, + router = router, courseId = "", infoType = "", ) @@ -215,7 +227,9 @@ class SignUpViewModelTest { preferencesManager = preferencesManager, appUpgradeNotifier = appUpgradeNotifier, oAuthHelper = oAuthHelper, + agreementProvider = agreementProvider, config = config, + router = router, courseId = "", infoType = "", ) @@ -242,7 +256,6 @@ class SignUpViewModelTest { assertEquals(somethingWrong, (deferred.await() as? UIMessage.SnackBarMessage)?.message) } - @Test fun `success register`() = runTest { val viewModel = SignUpViewModel( @@ -252,7 +265,9 @@ class SignUpViewModelTest { preferencesManager = preferencesManager, appUpgradeNotifier = appUpgradeNotifier, oAuthHelper = oAuthHelper, + agreementProvider = agreementProvider, config = config, + router = router, courseId = "", infoType = "", ) @@ -300,7 +315,9 @@ class SignUpViewModelTest { preferencesManager = preferencesManager, appUpgradeNotifier = appUpgradeNotifier, oAuthHelper = oAuthHelper, + agreementProvider = agreementProvider, config = config, + router = router, courseId = "", infoType = "", ) @@ -312,7 +329,7 @@ class SignUpViewModelTest { coVerify(exactly = 1) { interactor.getRegistrationFields() } verify(exactly = 1) { appUpgradeNotifier.notifier } - assertTrue(viewModel.uiState.value.isLoading) + assertFalse(viewModel.uiState.value.isLoading) assertEquals(noInternet, (deferred.await() as? UIMessage.SnackBarMessage)?.message) } @@ -325,7 +342,9 @@ class SignUpViewModelTest { preferencesManager = preferencesManager, appUpgradeNotifier = appUpgradeNotifier, oAuthHelper = oAuthHelper, + agreementProvider = agreementProvider, config = config, + router = router, courseId = "", infoType = "", ) @@ -337,7 +356,7 @@ class SignUpViewModelTest { coVerify(exactly = 1) { interactor.getRegistrationFields() } verify(exactly = 1) { appUpgradeNotifier.notifier } - assertTrue(viewModel.uiState.value.isLoading) + assertFalse(viewModel.uiState.value.isLoading) assertEquals(somethingWrong, (deferred.await() as? UIMessage.SnackBarMessage)?.message) } @@ -350,7 +369,9 @@ class SignUpViewModelTest { preferencesManager = preferencesManager, appUpgradeNotifier = appUpgradeNotifier, oAuthHelper = oAuthHelper, + agreementProvider = agreementProvider, config = config, + router = router, courseId = "", infoType = "", ) diff --git a/core/src/main/java/org/openedx/core/ApiConstants.kt b/core/src/main/java/org/openedx/core/ApiConstants.kt index 558df5434..786d63cc4 100644 --- a/core/src/main/java/org/openedx/core/ApiConstants.kt +++ b/core/src/main/java/org/openedx/core/ApiConstants.kt @@ -20,7 +20,6 @@ object ApiConstants { 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" const val PROVIDER = "provider" @@ -30,4 +29,9 @@ object ApiConstants { const val AUTH_TYPE_MICROSOFT = "azuread-oauth2" const val COURSE_KEY = "course_key" + + object RegistrationFields { + const val HONOR_CODE = "honor_code" + const val MARKETING_EMAILS = "marketing_emails_opt_in" + } } diff --git a/core/src/main/java/org/openedx/core/config/Config.kt b/core/src/main/java/org/openedx/core/config/Config.kt index cc1f37269..c6fb8f6e2 100644 --- a/core/src/main/java/org/openedx/core/config/Config.kt +++ b/core/src/main/java/org/openedx/core/config/Config.kt @@ -47,6 +47,10 @@ class Config(context: Context) { return getString(FEEDBACK_EMAIL_ADDRESS, "") } + fun getPlatformName(): String { + return getString(PLATFORM_NAME, "") + } + fun getAgreement(locale: String): AgreementUrls { val agreement = getObjectOrNewInstance(AGREEMENT_URLS, AgreementUrlsConfig::class.java).mapToDomain() @@ -168,6 +172,7 @@ class Config(context: Context) { 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" + private const val PLATFORM_NAME = "PLATFORM_NAME" } enum class ViewType { diff --git a/core/src/main/java/org/openedx/core/domain/model/RegistrationField.kt b/core/src/main/java/org/openedx/core/domain/model/RegistrationField.kt index 20b1af6eb..e4892edb5 100644 --- a/core/src/main/java/org/openedx/core/domain/model/RegistrationField.kt +++ b/core/src/main/java/org/openedx/core/domain/model/RegistrationField.kt @@ -2,6 +2,7 @@ package org.openedx.core.domain.model import android.os.Parcelable import kotlinx.parcelize.Parcelize +import org.openedx.core.ApiConstants data class RegistrationField( val name: String, @@ -13,7 +14,8 @@ data class RegistrationField( val required: Boolean, val restrictions: Restrictions, val options: List + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + () private val profileRouter by inject() + private val branchLogger = Logger(BRANCH_TAG) + private var _insetTop = 0 private var _insetBottom = 0 private var _insetCutout = 0 @@ -134,6 +140,43 @@ class AppActivity : AppCompatActivity(), InsetHolder, WindowSizeHolder { } } + override fun onStart() { + super.onStart() + + if (viewModel.isBranchEnabled) { + val callback = BranchUniversalReferralInitListener { _, linkProperties, error -> + if (linkProperties != null) { + branchLogger.i { "Branch init complete." } + branchLogger.i { linkProperties.controlParams.toString() } + } else if (error != null) { + branchLogger.e { "Branch init failed. Caused by -" + error.message } + } + } + + Branch.sessionBuilder(this) + .withCallback(callback) + .withData(this.intent.data) + .init() + } + } + + override fun onNewIntent(intent: Intent?) { + super.onNewIntent(intent) + this.intent = intent + + if (viewModel.isBranchEnabled) { + if (intent?.getBooleanExtra(BRANCH_FORCE_NEW_SESSION, false) == true) { + Branch.sessionBuilder(this).withCallback { referringParams, error -> + if (error != null) { + branchLogger.e { error.message } + } else if (referringParams != null) { + branchLogger.i { referringParams.toString() } + } + }.reInit() + } + } + } + private fun addFragment(fragment: Fragment) { supportFragmentManager.beginTransaction() .add(R.id.container, fragment) @@ -173,5 +216,7 @@ class AppActivity : AppCompatActivity(), InsetHolder, WindowSizeHolder { const val TOP_INSET = "topInset" const val BOTTOM_INSET = "bottomInset" const val CUTOUT_INSET = "cutoutInset" + const val BRANCH_TAG = "Branch" + const val BRANCH_FORCE_NEW_SESSION = "branch_force_new_session" } } diff --git a/app/src/main/java/org/openedx/app/AppViewModel.kt b/app/src/main/java/org/openedx/app/AppViewModel.kt index 9092f603c..84b8f8081 100644 --- a/app/src/main/java/org/openedx/app/AppViewModel.kt +++ b/app/src/main/java/org/openedx/app/AppViewModel.kt @@ -31,6 +31,8 @@ class AppViewModel( private var logoutHandledAt: Long = 0 + val isBranchEnabled get() = config.getBranchConfig().enabled + override fun onCreate(owner: LifecycleOwner) { super.onCreate(owner) setUserId() @@ -55,4 +57,4 @@ class AppViewModel( } } -} \ No newline at end of file +} diff --git a/app/src/main/java/org/openedx/app/OpenEdXApp.kt b/app/src/main/java/org/openedx/app/OpenEdXApp.kt index 865290940..b5aa926e1 100644 --- a/app/src/main/java/org/openedx/app/OpenEdXApp.kt +++ b/app/src/main/java/org/openedx/app/OpenEdXApp.kt @@ -1,9 +1,9 @@ package org.openedx.app import android.app.Application -import com.google.firebase.FirebaseOptions import com.google.firebase.ktx.Firebase import com.google.firebase.ktx.initialize +import io.branch.referral.Branch import org.koin.android.ext.android.inject import org.koin.android.ext.koin.androidContext import org.koin.core.context.startKoin @@ -29,6 +29,13 @@ class OpenEdXApp : Application() { if (config.getFirebaseConfig().enabled) { Firebase.initialize(this) } - } -} \ No newline at end of file + if (config.getBranchConfig().enabled) { + if (BuildConfig.DEBUG) { + Branch.enableTestMode() + Branch.enableLogging() + } + Branch.getAutoInstance(this) + } + } +} diff --git a/core/src/main/java/org/openedx/core/config/BranchConfig.kt b/core/src/main/java/org/openedx/core/config/BranchConfig.kt new file mode 100644 index 000000000..363e89fa6 --- /dev/null +++ b/core/src/main/java/org/openedx/core/config/BranchConfig.kt @@ -0,0 +1,20 @@ +package org.openedx.core.config + +import com.google.gson.annotations.SerializedName + +data class BranchConfig( + @SerializedName("ENABLED") + val enabled: Boolean = false, + + @SerializedName("KEY") + val key: String = "", + + @SerializedName("URI_SCHEME") + val uriScheme: String = "", + + @SerializedName("HOST") + val host: String = "", + + @SerializedName("ALTERNATE_HOST") + val alternateHost: String = "", +) diff --git a/core/src/main/java/org/openedx/core/config/Config.kt b/core/src/main/java/org/openedx/core/config/Config.kt index c6fb8f6e2..9f626cc2e 100644 --- a/core/src/main/java/org/openedx/core/config/Config.kt +++ b/core/src/main/java/org/openedx/core/config/Config.kt @@ -91,6 +91,10 @@ class Config(context: Context) { return getObjectOrNewInstance(PROGRAM, ProgramConfig::class.java) } + fun getBranchConfig(): BranchConfig { + return getObjectOrNewInstance(BRANCH, BranchConfig::class.java) + } + fun isWhatsNewEnabled(): Boolean { return getBoolean(WHATS_NEW_ENABLED, false) } @@ -168,6 +172,7 @@ class Config(context: Context) { private const val PRE_LOGIN_EXPERIENCE_ENABLED = "PRE_LOGIN_EXPERIENCE_ENABLED" private const val DISCOVERY = "DISCOVERY" private const val PROGRAM = "PROGRAM" + private const val BRANCH = "BRANCH" 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" diff --git a/core/src/main/java/org/openedx/core/utils/Logger.kt b/core/src/main/java/org/openedx/core/utils/Logger.kt index f6bb4ecb0..41cd9a3a6 100644 --- a/core/src/main/java/org/openedx/core/utils/Logger.kt +++ b/core/src/main/java/org/openedx/core/utils/Logger.kt @@ -12,6 +12,10 @@ class Logger(private val tag: String) { if (BuildConfig.DEBUG) Log.e(tag, message()) } + fun i(message: () -> String) { + if (BuildConfig.DEBUG) Log.i(tag, message()) + } + fun w(message: () -> String) { if (BuildConfig.DEBUG) Log.w(tag, message()) } diff --git a/default_config/dev/config.yaml b/default_config/dev/config.yaml index 09c746827..59372a8ef 100644 --- a/default_config/dev/config.yaml +++ b/default_config/dev/config.yaml @@ -59,6 +59,13 @@ FACEBOOK: FACEBOOK_APP_ID: '' CLIENT_TOKEN: '' +BRANCH: + ENABLED: false + KEY: '' + URI_SCHEME: '' + HOST: '' + ALTERNATE_HOST: '' + #Platform names PLATFORM_NAME: "OpenEdX" PLATFORM_FULL_NAME: "OpenEdX" diff --git a/default_config/prod/config.yaml b/default_config/prod/config.yaml index 3cc0b6592..423ca0b3c 100644 --- a/default_config/prod/config.yaml +++ b/default_config/prod/config.yaml @@ -59,6 +59,13 @@ FACEBOOK: FACEBOOK_APP_ID: '' CLIENT_TOKEN: '' +BRANCH: + ENABLED: false + KEY: '' + URI_SCHEME: '' + HOST: '' + ALTERNATE_HOST: '' + #Platform names PLATFORM_NAME: "OpenEdX" PLATFORM_FULL_NAME: "OpenEdX" diff --git a/default_config/stage/config.yaml b/default_config/stage/config.yaml index 3cc0b6592..423ca0b3c 100644 --- a/default_config/stage/config.yaml +++ b/default_config/stage/config.yaml @@ -59,6 +59,13 @@ FACEBOOK: FACEBOOK_APP_ID: '' CLIENT_TOKEN: '' +BRANCH: + ENABLED: false + KEY: '' + URI_SCHEME: '' + HOST: '' + ALTERNATE_HOST: '' + #Platform names PLATFORM_NAME: "OpenEdX" PLATFORM_FULL_NAME: "OpenEdX" From 8ec0f73e3c66a8a8d0b16598ae93a7c84fde2e75 Mon Sep 17 00:00:00 2001 From: Omer Habib <30689349+omerhabib26@users.noreply.github.com> Date: Wed, 28 Feb 2024 16:07:04 +0500 Subject: [PATCH 14/39] fix: Adjust padding on WebContentScreen (#241) - Adjust padding and text on toolbar - Remove padding from WebContentScreen fix: LEARNER-9867 --- core/src/main/java/org/openedx/core/ui/ComposeCommon.kt | 5 +++-- .../main/java/org/openedx/core/ui/WebContentScreen.kt | 9 ++------- 2 files changed, 5 insertions(+), 9 deletions(-) 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 e21307266..afa8d7b12 100644 --- a/core/src/main/java/org/openedx/core/ui/ComposeCommon.kt +++ b/core/src/main/java/org/openedx/core/ui/ComposeCommon.kt @@ -152,7 +152,7 @@ fun Toolbar( canShowBackBtn: Boolean = false, onBackClick: () -> Unit = {} ) { - Box( + Row( modifier = modifier .fillMaxWidth() .height(48.dp), @@ -164,7 +164,8 @@ fun Toolbar( Text( modifier = Modifier .testTag("txt_toolbar_title") - .align(Alignment.Center), + .align(Alignment.CenterVertically) + .padding(end = 16.dp), text = label, color = MaterialTheme.appColors.textPrimary, style = MaterialTheme.appTypography.titleMedium, diff --git a/core/src/main/java/org/openedx/core/ui/WebContentScreen.kt b/core/src/main/java/org/openedx/core/ui/WebContentScreen.kt index a79200111..dd45444da 100644 --- a/core/src/main/java/org/openedx/core/ui/WebContentScreen.kt +++ b/core/src/main/java/org/openedx/core/ui/WebContentScreen.kt @@ -10,10 +10,8 @@ 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 @@ -59,7 +57,7 @@ fun WebContentScreen( Scaffold( modifier = Modifier .fillMaxSize() - .padding(bottom = 16.dp) + .padding(bottom = 24.dp) .semantics { testTagsAsResourceId = true }, @@ -97,7 +95,6 @@ fun WebContentScreen( onBackClick = onBackClick ) } - Spacer(Modifier.height(6.dp)) Surface( Modifier.fillMaxSize(), color = MaterialTheme.appColors.background @@ -115,9 +112,7 @@ fun WebContentScreen( } else { var webViewAlpha by rememberSaveable { mutableFloatStateOf(0f) } Surface( - Modifier - .padding(horizontal = 16.dp, vertical = 24.dp) - .alpha(webViewAlpha), + Modifier.alpha(webViewAlpha), color = MaterialTheme.appColors.background ) { WebViewContent( From d38625a71fadf84744b253bf01535f278116fee7 Mon Sep 17 00:00:00 2001 From: Igor Date: Thu, 29 Feb 2024 13:34:38 +0100 Subject: [PATCH 15/39] fix: bug when data is not displayed on the Hangouts and Announcement screens (#242) --- .../java/org/openedx/core/ui/WebContentScreen.kt | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/core/src/main/java/org/openedx/core/ui/WebContentScreen.kt b/core/src/main/java/org/openedx/core/ui/WebContentScreen.kt index dd45444da..c9c7c4ba1 100644 --- a/core/src/main/java/org/openedx/core/ui/WebContentScreen.kt +++ b/core/src/main/java/org/openedx/core/ui/WebContentScreen.kt @@ -197,5 +197,19 @@ private fun WebViewContent( } } }, + update = { webView -> + body?.let { + webView.loadDataWithBaseURL( + apiHostUrl, + body.replaceLinkTags(isDarkTheme), + "text/html", + StandardCharsets.UTF_8.name(), + null + ) + } + contentUrl?.let { + webView.loadUrl(it) + } + } ) } From f5847d2670463de1076f35eca7affa8b06a3fa6f Mon Sep 17 00:00:00 2001 From: Volodymyr Chekyrta <127732735+volodymyr-chekyrta@users.noreply.github.com> Date: Tue, 12 Mar 2024 09:57:05 +0200 Subject: [PATCH 16/39] chore: Update dependency versions (#253) Gradle 8.0 -> 8.4 Kotlin 1.8.21 -> 1.9.22 Compose 1.5.0 -> 1.6.2 Compose Compiler 1.4.7 -> 1.5.10 Material 1.9.0 -> 1.11.0 Lifecycle 2.6.1 -> 2.7.0 Fragment 1.6.1 -> 1.6.2 Room 2.5.2 -> 2.6.1 WorkManager 2.8.1 -> 2.9.0 Window 1.1.0 -> 1.2.0 play-services-auth 20.7.0 -> 21.0.0 --- auth/build.gradle | 9 +++++++-- build.gradle | 22 +++++++++++----------- gradle/wrapper/gradle-wrapper.properties | 2 +- 3 files changed, 19 insertions(+), 14 deletions(-) diff --git a/auth/build.gradle b/auth/build.gradle index 02b94a587..7cf4d0a86 100644 --- a/auth/build.gradle +++ b/auth/build.gradle @@ -58,9 +58,14 @@ dependencies { 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.gms:play-services-auth:21.0.0" implementation "com.google.android.libraries.identity.googleid:googleid:1.1.0" - implementation 'com.microsoft.identity.client:msal:4.9.0' + implementation("com.microsoft.identity.client:msal:4.9.0") { + //Workaround for the error Failed to resolve: 'io.opentelemetry:opentelemetry-bom' for AS Iguana + exclude(group: "io.opentelemetry") + } + implementation("io.opentelemetry:opentelemetry-api:1.18.0") + implementation("io.opentelemetry:opentelemetry-context:1.18.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" diff --git a/build.gradle b/build.gradle index 462369006..8e61cf92d 100644 --- a/build.gradle +++ b/build.gradle @@ -5,16 +5,16 @@ import java.util.regex.Pattern buildscript { ext { - kotlin_version = '1.8.21' + kotlin_version = '1.9.22' coroutines_version = '1.7.1' - compose_version = '1.5.0' - compose_compiler_version = '1.4.7' + compose_version = '1.6.2' + compose_compiler_version = '1.5.10' } } plugins { - id 'com.android.application' version '8.1.1' apply false - id 'com.android.library' version '8.1.1' apply false + id 'com.android.application' version '8.3.0' apply false + id 'com.android.library' version '8.3.0' apply false id 'org.jetbrains.kotlin.android' version "$kotlin_version" apply false id 'com.google.gms.google-services' version '4.3.15' apply false id "com.google.firebase.crashlytics" version "2.9.6" apply false @@ -27,9 +27,9 @@ task clean(type: Delete) { ext { core_version = "1.10.1" appcompat_version = "1.6.1" - material_version = "1.9.0" - lifecycle_version = "2.6.1" - fragment_version = "1.6.1" + material_version = "1.11.0" + lifecycle_version = "2.7.0" + fragment_version = "1.6.2" constraintlayout_version = "2.1.4" viewpager2_version = "1.0.0" media3_version = "1.1.1" @@ -46,11 +46,11 @@ ext { jsoup_version = '1.13.1' - room_version = '2.5.2' + room_version = '2.6.1' - work_version = '2.8.1' + work_version = '2.9.0' - window_version = '1.1.0' + window_version = '1.2.0' in_app_review = '2.0.1' diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index c90152329..4166a41c2 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,6 +1,6 @@ #Mon Sep 12 17:38:01 EEST 2022 distributionBase=GRADLE_USER_HOME -distributionUrl=https\://services.gradle.org/distributions/gradle-8.0-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-8.4-bin.zip distributionPath=wrapper/dists zipStorePath=wrapper/dists zipStoreBase=GRADLE_USER_HOME From 9df8df2fd54defe06a0adf2892b5c60d3507df22 Mon Sep 17 00:00:00 2001 From: Igor Date: Tue, 12 Mar 2024 14:28:33 +0100 Subject: [PATCH 17/39] fix: bug when video continues to play when exiting content (#251) --- .../presentation/ChapterEndFragmentDialog.kt | 33 ++++-- .../container/CourseUnitContainerFragment.kt | 106 +++++++++++++----- 2 files changed, 99 insertions(+), 40 deletions(-) diff --git a/course/src/main/java/org/openedx/course/presentation/ChapterEndFragmentDialog.kt b/course/src/main/java/org/openedx/course/presentation/ChapterEndFragmentDialog.kt index 32c48b433..cdedd3424 100644 --- a/course/src/main/java/org/openedx/course/presentation/ChapterEndFragmentDialog.kt +++ b/course/src/main/java/org/openedx/course/presentation/ChapterEndFragmentDialog.kt @@ -8,8 +8,21 @@ import android.graphics.drawable.ColorDrawable import android.os.Bundle import android.view.LayoutInflater import android.view.ViewGroup -import androidx.compose.foundation.layout.* -import androidx.compose.material.* +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.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.material.Card +import androidx.compose.material.Icon +import androidx.compose.material.IconButton +import androidx.compose.material.MaterialTheme +import androidx.compose.material.Text import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Close import androidx.compose.runtime.Composable @@ -37,7 +50,6 @@ import org.openedx.core.ui.theme.appColors import org.openedx.core.ui.theme.appShapes import org.openedx.core.ui.theme.appTypography import org.openedx.course.R -import org.openedx.course.presentation.section.CourseSectionFragment class ChapterEndFragmentDialog : DialogFragment() { @@ -72,10 +84,6 @@ class ChapterEndFragmentDialog : DialogFragment() { onBackButtonClick = { dismiss() listener?.onDismiss() - requireActivity().supportFragmentManager.popBackStack( - CourseSectionFragment::class.java.simpleName, - 0 - ) }, onProceedButtonClick = { dismiss() @@ -92,10 +100,6 @@ class ChapterEndFragmentDialog : DialogFragment() { onBackButtonClick = { dismiss() listener?.onDismiss() - requireActivity().supportFragmentManager.popBackStack( - CourseSectionFragment::class.java.simpleName, - 0 - ) }, onProceedButtonClick = { dismiss() @@ -110,6 +114,11 @@ class ChapterEndFragmentDialog : DialogFragment() { } } + override fun onDestroy() { + listener = null + super.onDestroy() + } + companion object { private const val ARG_SECTION_NAME = "sectionName" private const val ARG_NEXT_SECTION_NAME = "nexSectionName" @@ -383,4 +392,4 @@ fun ChapterEndDialogScreenLandscapePreview() { onCancelButtonClick = {} ) } -} \ No newline at end of file +} diff --git a/course/src/main/java/org/openedx/course/presentation/unit/container/CourseUnitContainerFragment.kt b/course/src/main/java/org/openedx/course/presentation/unit/container/CourseUnitContainerFragment.kt index fb6cd7a2d..a4d6de5bb 100644 --- a/course/src/main/java/org/openedx/course/presentation/unit/container/CourseUnitContainerFragment.kt +++ b/course/src/main/java/org/openedx/course/presentation/unit/container/CourseUnitContainerFragment.kt @@ -5,11 +5,16 @@ import android.os.SystemClock import android.view.LayoutInflater import android.view.View import android.view.ViewGroup +import androidx.activity.OnBackPressedCallback import androidx.compose.foundation.layout.width import androidx.compose.material.MaterialTheme -import androidx.compose.runtime.* +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue import androidx.compose.runtime.livedata.observeAsState +import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp import androidx.constraintlayout.widget.ConstraintLayout @@ -37,7 +42,12 @@ import org.openedx.course.databinding.FragmentCourseUnitContainerBinding import org.openedx.course.presentation.ChapterEndFragmentDialog import org.openedx.course.presentation.CourseRouter import org.openedx.course.presentation.DialogListener -import org.openedx.course.presentation.ui.* +import org.openedx.course.presentation.ui.CourseUnitToolbar +import org.openedx.course.presentation.ui.HorizontalPageIndicator +import org.openedx.course.presentation.ui.NavigationUnitsButtons +import org.openedx.course.presentation.ui.SubSectionUnitsList +import org.openedx.course.presentation.ui.SubSectionUnitsTitle +import org.openedx.course.presentation.ui.VerticalPageIndicator class CourseUnitContainerFragment : Fragment(R.layout.fragment_course_unit_container) { @@ -72,6 +82,53 @@ class CourseUnitContainerFragment : Fragment(R.layout.fragment_course_unit_conta } } + private val dialogListener = object : DialogListener { + override fun onClick(value: T) { + viewModel.proceedToNext() + val nextBlock = viewModel.getCurrentVerticalBlock() + nextBlock?.let { + viewModel.finishVerticalNextClickedEvent( + it.blockId, + it.displayName + ) + if (it.type.isContainer()) { + router.navigateToCourseContainer( + fm = requireActivity().supportFragmentManager, + courseId = viewModel.courseId, + unitId = it.id, + mode = requireArguments().serializable(ARG_MODE)!! + ) + } + } + } + + override fun onDismiss() { + viewModel.finishVerticalBackClickedEvent() + navigateToParentFragment() + } + } + + // Start workaround to fix an issue when onDestroy is not called after one fragment + // was replaced with another using the 'FragmentManager.replace()' function + private val onBackPressedCallback = object : OnBackPressedCallback(true) { + override fun handleOnBackPressed() { + navigateToParentFragment() + } + } + + private fun navigateToParentFragment() { + activity?.supportFragmentManager?.let { fm -> + for (i in fm.backStackEntryCount - 1 downTo 0) { + val entryName = fm.getBackStackEntryAt(i).name + if (entryName != CourseUnitContainerFragment::class.simpleName) { + fm.popBackStack(entryName, 0) + return + } + } + } + } + // End workaround + override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) lifecycle.addObserver(viewModel) @@ -179,7 +236,7 @@ class CourseUnitContainerFragment : Fragment(R.layout.fragment_course_unit_conta CourseUnitToolbar( title = title, onBackClick = { - requireActivity().supportFragmentManager.popBackStack() + navigateToParentFragment() } ) } @@ -212,7 +269,7 @@ class CourseUnitContainerFragment : Fragment(R.layout.fragment_course_unit_conta selectedUnitIndex = selectedUnitIndex ) { index, unit -> if (index != selectedUnitIndex) { - router.replaceCourseContainer( + router.navigateToCourseContainer( fm = requireActivity().supportFragmentManager, courseId = viewModel.courseId, unitId = unit.id, @@ -231,6 +288,22 @@ class CourseUnitContainerFragment : Fragment(R.layout.fragment_course_unit_conta } if (viewModel.unitsListShowed.value == true) handleUnitsClick() + + val chapterEndDialogTag = ChapterEndFragmentDialog::class.simpleName + (requireActivity().supportFragmentManager + .findFragmentByTag(chapterEndDialogTag) as? ChapterEndFragmentDialog)?.let { fragment -> + fragment.listener = dialogListener + } + } + + override fun onResume() { + super.onResume() + activity?.onBackPressedDispatcher?.addCallback(onBackPressedCallback) + } + + override fun onPause() { + onBackPressedCallback.remove() + super.onPause() } override fun onDestroyView() { @@ -313,30 +386,7 @@ class CourseUnitContainerFragment : Fragment(R.layout.fragment_course_unit_conta it.displayName ) } - dialog.listener = object : DialogListener { - override fun onClick(value: T) { - viewModel.proceedToNext() - val nextBlock = viewModel.getCurrentVerticalBlock() - nextBlock?.let { - viewModel.finishVerticalNextClickedEvent( - it.blockId, - it.displayName - ) - if (it.type.isContainer()) { - router.replaceCourseContainer( - fm = requireActivity().supportFragmentManager, - courseId = viewModel.courseId, - unitId = it.id, - mode = requireArguments().serializable(ARG_MODE)!! - ) - } - } - } - - override fun onDismiss() { - viewModel.finishVerticalBackClickedEvent() - } - } + dialog.listener = dialogListener dialog.show( requireActivity().supportFragmentManager, ChapterEndFragmentDialog::class.simpleName From f91927b4addaf61e25b63c1814a54a460160ecb9 Mon Sep 17 00:00:00 2001 From: Igor Date: Thu, 14 Mar 2024 15:46:36 +0100 Subject: [PATCH 18/39] fix: crash after rotating device in open content (#256) * fix: crash after rotating device in open content * refactor: removed unused variable --- .../course/presentation/unit/html/HtmlUnitFragment.kt | 2 +- .../presentation/unit/video/EncodedVideoUnitViewModel.kt | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/course/src/main/java/org/openedx/course/presentation/unit/html/HtmlUnitFragment.kt b/course/src/main/java/org/openedx/course/presentation/unit/html/HtmlUnitFragment.kt index 124ceb327..a74b9d5ee 100644 --- a/course/src/main/java/org/openedx/course/presentation/unit/html/HtmlUnitFragment.kt +++ b/course/src/main/java/org/openedx/course/presentation/unit/html/HtmlUnitFragment.kt @@ -118,7 +118,7 @@ class HtmlUnitFragment : Fragment() { }, onWebPageLoaded = { isLoading = false - viewModel.setWebPageLoaded(requireContext().assets) + if (isAdded) viewModel.setWebPageLoaded(requireContext().assets) } ) } else { diff --git a/course/src/main/java/org/openedx/course/presentation/unit/video/EncodedVideoUnitViewModel.kt b/course/src/main/java/org/openedx/course/presentation/unit/video/EncodedVideoUnitViewModel.kt index 1ff0df22e..c49357161 100644 --- a/course/src/main/java/org/openedx/course/presentation/unit/video/EncodedVideoUnitViewModel.kt +++ b/course/src/main/java/org/openedx/course/presentation/unit/video/EncodedVideoUnitViewModel.kt @@ -50,7 +50,6 @@ class EncodedVideoUnitViewModel( private set var castPlayer: CastPlayer? = null private set - private var castContext: CastContext? = null var isCastActive = false @@ -80,9 +79,10 @@ class EncodedVideoUnitViewModel( initPlayer() val executor = Executors.newSingleThreadExecutor() - castContext = CastContext.getSharedInstance(context, executor).result - castContext?.let { - castPlayer = CastPlayer(it) + CastContext.getSharedInstance(context, executor).addOnCompleteListener { + it.result?.let { castContext -> + castPlayer = CastPlayer(castContext) + } } } From 9b7f73f4f1635f96ad77ef2fe71b5b02f809fd55 Mon Sep 17 00:00:00 2001 From: Igor Date: Fri, 15 Mar 2024 15:27:00 +0100 Subject: [PATCH 19/39] fix: crash when trying to open a youtube video when offline (#257) * fix: crash when trying to open a youtube video when offline * refactor: added utility functions inside EncodedVideos for videoUrls --- .../org/openedx/core/domain/model/Block.kt | 14 +++++++++---- .../container/CourseUnitContainerAdapter.kt | 20 +++++-------------- .../unit/video/YoutubeVideoUnitFragment.kt | 20 +++++++++++++------ 3 files changed, 29 insertions(+), 25 deletions(-) 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 1c69142a8..2f1766ecb 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 @@ -117,13 +117,19 @@ data class EncodedVideos( || fallback?.url != null val videoUrl: String - get() = mobileHigh?.url - ?: mobileLow?.url - ?: desktopMp4?.url + get() = fallback?.url ?: hls?.url - ?: fallback?.url + ?: desktopMp4?.url + ?: mobileHigh?.url + ?: mobileLow?.url ?: "" + val hasVideoUrl: Boolean + get() = videoUrl.isNotEmpty() + + val hasYoutubeUrl: Boolean + get() = youtube?.url?.isNotEmpty() == true + fun getPreferredVideoInfoForDownloading(preferredVideoQuality: VideoQuality): VideoInfo? { var preferredVideoInfo = when (preferredVideoQuality) { VideoQuality.OPTION_360P -> mobileLow diff --git a/course/src/main/java/org/openedx/course/presentation/unit/container/CourseUnitContainerAdapter.kt b/course/src/main/java/org/openedx/course/presentation/unit/container/CourseUnitContainerAdapter.kt index 5dc12b617..958d479c1 100644 --- a/course/src/main/java/org/openedx/course/presentation/unit/container/CourseUnitContainerAdapter.kt +++ b/course/src/main/java/org/openedx/course/presentation/unit/container/CourseUnitContainerAdapter.kt @@ -23,7 +23,9 @@ class CourseUnitContainerAdapter( private fun unitBlockFragment(block: Block): Fragment { return when { - (block.isVideoBlock && block.studentViewData?.encodedVideos != null) -> { + (block.isVideoBlock && + (block.studentViewData?.encodedVideos?.hasVideoUrl == true || + block.studentViewData?.encodedVideos?.hasYoutubeUrl == true)) -> { val encodedVideos = block.studentViewData?.encodedVideos!! val transcripts = block.studentViewData!!.transcripts with(encodedVideos) { @@ -31,19 +33,7 @@ class CourseUnitContainerAdapter( val videoUrl = if (viewModel.getDownloadModelById(block.id) != null) { isDownloaded = true viewModel.getDownloadModelById(block.id)!!.path - } else if (fallback != null) { - fallback!!.url - } else if (hls != null) { - hls!!.url - } else if (desktopMp4 != null) { - desktopMp4!!.url - } else if (mobileHigh != null) { - mobileHigh!!.url - } else if (mobileLow != null) { - mobileLow!!.url - } else { - "" - } + } else videoUrl if (videoUrl.isNotEmpty()) { VideoUnitFragment.newInstance( block.id, @@ -57,7 +47,7 @@ class CourseUnitContainerAdapter( YoutubeVideoUnitFragment.newInstance( block.id, viewModel.courseId, - encodedVideos.youtube?.url!!, + encodedVideos.youtube?.url ?: "", transcripts?.toMap() ?: emptyMap(), block.displayName ) diff --git a/course/src/main/java/org/openedx/course/presentation/unit/video/YoutubeVideoUnitFragment.kt b/course/src/main/java/org/openedx/course/presentation/unit/video/YoutubeVideoUnitFragment.kt index bdf47e990..a842f4175 100644 --- a/course/src/main/java/org/openedx/course/presentation/unit/video/YoutubeVideoUnitFragment.kt +++ b/course/src/main/java/org/openedx/course/presentation/unit/video/YoutubeVideoUnitFragment.kt @@ -161,7 +161,10 @@ class YoutubeVideoUnitFragment : Fragment(R.layout.fragment_youtube_video_unit) } } - override fun onStateChange(youTubePlayer: YouTubePlayer, state: PlayerConstants.PlayerState) { + override fun onStateChange( + youTubePlayer: YouTubePlayer, + state: PlayerConstants.PlayerState + ) { super.onStateChange(youTubePlayer, state) viewModel.isPlaying = when (state) { PlayerConstants.PlayerState.PLAYING -> true @@ -191,11 +194,16 @@ class YoutubeVideoUnitFragment : Fragment(R.layout.fragment_youtube_video_unit) binding.youtubePlayerView.setCustomPlayerUi(defPlayerUiController.rootView) } - val videoId = viewModel.videoUrl.split("watch?v=")[1] - if (viewModel.isPlaying) { - youTubePlayer.loadVideo(videoId, viewModel.getCurrentVideoTime().toFloat() / 1000) - } else { - youTubePlayer.cueVideo(videoId, viewModel.getCurrentVideoTime().toFloat() / 1000) + viewModel.videoUrl.split("watch?v=").getOrNull(1)?.let { videoId -> + if (viewModel.isPlaying) { + youTubePlayer.loadVideo( + videoId, viewModel.getCurrentVideoTime().toFloat() / 1000 + ) + } else { + youTubePlayer.cueVideo( + videoId, viewModel.getCurrentVideoTime().toFloat() / 1000 + ) + } } youTubePlayer.addListener(youtubeTrackerListener) } From 53fe954e28e30994a383a382d8ba95e59a061680 Mon Sep 17 00:00:00 2001 From: Hamza Israr Date: Sat, 16 Mar 2024 01:43:23 +0500 Subject: [PATCH 20/39] feat: Add Braze Push Notifications Capabilities Fixes: LEARNER-9875 --- app/build.gradle | 15 +++++++++++++++ app/src/main/AndroidManifest.xml | 10 ++++++++++ app/src/main/java/org/openedx/app/OpenEdXApp.kt | 15 +++++++++++++++ .../org/openedx/core/config/FirebaseConfig.kt | 3 +++ settings.gradle | 8 ++++++++ 5 files changed, 51 insertions(+) diff --git a/app/build.gradle b/app/build.gradle index 10cf3ab13..1e2f61772 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -47,14 +47,17 @@ android { prod { dimension 'env' setupBranchConfigFields(it) + setupFirebaseConfigFields(it) } develop { dimension 'env' setupBranchConfigFields(it) + setupFirebaseConfigFields(it) } stage { dimension 'env' setupBranchConfigFields(it) + setupFirebaseConfigFields(it) } } @@ -132,6 +135,10 @@ dependencies { implementation 'com.segment.analytics.kotlin.destinations:firebase:1.5.2' // Braze SDK Integration implementation "com.braze:braze-segment-kotlin:1.4.2" + implementation "com.braze:android-sdk-ui:30.2.0" + + // Firebase Cloud Messaging Integration for Braze + implementation 'com.google.firebase:firebase-messaging-ktx:23.4.1' // Branch SDK Integration implementation 'io.branch.sdk.android:library:5.9.0' @@ -174,3 +181,11 @@ private def setupBranchConfigFields(buildType) { buildType.resValue "string", "branch_host", branchHost buildType.resValue "string", "branch_alternate_host", branchAlternateHost } + +private def setupFirebaseConfigFields(buildType) { + def firebaseConfig = configHelper.fetchConfig().get('FIREBASE') + def firebaseEnabled = firebaseConfig?.getOrDefault('ENABLED', false) + def cloudMessagingEnabled = firebaseConfig?.getOrDefault('CLOUD_MESSAGING_ENABLED', false) + + buildType.manifestPlaceholders = [fcmEnabled: firebaseEnabled && cloudMessagingEnabled] +} diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 1fd5fd385..8020f6b74 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -101,6 +101,16 @@ android:name="androidx.work.impl.foreground.SystemForegroundService" android:foregroundServiceType="dataSync" tools:node="merge" /> + + + + + + + diff --git a/app/src/main/java/org/openedx/app/OpenEdXApp.kt b/app/src/main/java/org/openedx/app/OpenEdXApp.kt index b5aa926e1..0caa89137 100644 --- a/app/src/main/java/org/openedx/app/OpenEdXApp.kt +++ b/app/src/main/java/org/openedx/app/OpenEdXApp.kt @@ -1,6 +1,8 @@ package org.openedx.app import android.app.Application +import com.braze.Braze +import com.braze.configuration.BrazeConfig import com.google.firebase.ktx.Firebase import com.google.firebase.ktx.initialize import io.branch.referral.Branch @@ -37,5 +39,18 @@ class OpenEdXApp : Application() { } Branch.getAutoInstance(this) } + + if (config.getBrazeConfig().isEnabled && config.getFirebaseConfig().enabled) { + val isCloudMessagingEnabled = config.getFirebaseConfig().isCloudMessagingEnabled && + config.getBrazeConfig().isPushNotificationsEnabled + + val brazeConfig = BrazeConfig.Builder() + .setIsFirebaseCloudMessagingRegistrationEnabled(isCloudMessagingEnabled) + .setFirebaseCloudMessagingSenderIdKey(config.getFirebaseConfig().projectNumber) + .setHandlePushDeepLinksAutomatically(true) + .setIsFirebaseMessagingServiceOnNewTokenRegistrationEnabled(true) + .build() + Braze.configure(this, brazeConfig) + } } } diff --git a/core/src/main/java/org/openedx/core/config/FirebaseConfig.kt b/core/src/main/java/org/openedx/core/config/FirebaseConfig.kt index b8257d002..f5b2e9136 100644 --- a/core/src/main/java/org/openedx/core/config/FirebaseConfig.kt +++ b/core/src/main/java/org/openedx/core/config/FirebaseConfig.kt @@ -9,6 +9,9 @@ data class FirebaseConfig( @SerializedName("ANALYTICS_SOURCE") val analyticsSource: AnalyticsSource = AnalyticsSource.NONE, + @SerializedName("CLOUD_MESSAGING_ENABLED") + val isCloudMessagingEnabled: Boolean = false, + @SerializedName("PROJECT_NUMBER") val projectNumber: String = "", diff --git a/settings.gradle b/settings.gradle index e0a869615..205f840ee 100644 --- a/settings.gradle +++ b/settings.gradle @@ -21,6 +21,14 @@ dependencyResolutionManagement { repositories { google() mavenCentral() + maven { + url "http://appboy.github.io/appboy-android-sdk/sdk" + allowInsecureProtocol = true + } + maven { + url "https://appboy.github.io/appboy-segment-android/sdk" + allowInsecureProtocol = true + } maven { url "https://pkgs.dev.azure.com/MicrosoftDeviceSDK/DuoSDK-Public/_packaging/Duo-SDK-Feed/maven/v1" } } } From beedcb3f25ffadc6f50c35b95b9c1588f2b831e6 Mon Sep 17 00:00:00 2001 From: Volodymyr Chekyrta <127732735+volodymyr-chekyrta@users.noreply.github.com> Date: Mon, 18 Mar 2024 11:12:18 +0200 Subject: [PATCH 21/39] docs: Update project documentation (#255) * docs: Updating the readme and creating configuration management documentation. * fix: Feedback addressed --- Documentation/APIs_Compatibility.md | 11 +++ Documentation/ConfigurationManagement.md | 114 +++++++++++++++++++++++ README.md | 13 ++- 3 files changed, 131 insertions(+), 7 deletions(-) create mode 100644 Documentation/APIs_Compatibility.md create mode 100644 Documentation/ConfigurationManagement.md diff --git a/Documentation/APIs_Compatibility.md b/Documentation/APIs_Compatibility.md new file mode 100644 index 000000000..2867b07df --- /dev/null +++ b/Documentation/APIs_Compatibility.md @@ -0,0 +1,11 @@ +# APIs Compatibility + +This documentation offers guidance on a workaround for utilizing mobile APIs with earlier versions of Open edX releases. + +In December 2023, the [FC-0031 project](https://github.com/openedx/edx-platform/issues/33304) introduced new APIs, and the Open edX mobile apps were transitioned to utilize them. + +If your platform version is older than December 2023, follow the instructions below: + +1. Setup the [mobile-api-extensions](https://github.com/raccoongang/mobile-api-extensions) plugin to your platform. +The Plugin contains extended Open edX APIs for mobile applications. +2. Roll back the modifications that brought in the new APIs [42f518a](https://github.com/openedx/openedx-app-android/commit/42f518a264d4300c8c2ca349072addd7d16ff91a). diff --git a/Documentation/ConfigurationManagement.md b/Documentation/ConfigurationManagement.md new file mode 100644 index 000000000..1aed9a5ab --- /dev/null +++ b/Documentation/ConfigurationManagement.md @@ -0,0 +1,114 @@ +# Configuration Management + +This documentation provides a comprehensive solution for integrating and managing configuration files in Open edX Android project. + +## Features +- Parsing config.yaml files +- Adding essential keys to `AndroidManifest.xml` (e.g. Microsoft keys) +- Generating Android build config fields. +- Generating config.json file to use the configuration fields at runtime. +- Generating google-services.json with Firebase keys. + +Inside the `Config.kt`, parsing and populating relevant keys and classes are done, e.g. `AgreementUrlsConfig.kt` and `FirebaseConfig.kt`. + +## Getting Started + +### Configuration Setup + +Edit the `config_settings.yaml` in the `default_config` folder. It should contain data as follows: + +```yaml +config_directory: '{path_to_config_folder}' +config_mapping: + prod: 'prod' + stage: 'stage' + dev: 'dev' +# These mappings are configurable, e.g. dev: 'prod_test' +``` + +- `config_directory` provides the path of the config directory. +- `config_mappings` provides mappings that can be utilized to map the Android Build Variant to a defined folder within the config directory, and it will be referenced. + +Note: You can specify `config_directory` to any folder outside the repository to store the configs as a separate project. + +### Configuration Files +In the `default_config` folder, select your environment folder: prod, stage, dev or any other you have created. +Open `config.yaml` and fill in the required fields. + +Example: + +```yaml +API_HOST_URL: 'https://mylmsexample.com' +APPLICATION_ID: 'org.openedx.app' +ENVIRONMENT_DISPLAY_NAME: 'MyLMSExample' +FEEDBACK_EMAIL_ADDRESS: 'support@mylmsexample.com' +OAUTH_CLIENT_ID: 'YOUR_OAUTH_CLIENT_ID' + +PLATFORM_NAME: "MyLMS" +TOKEN_TYPE: "JWT" + +FIREBASE: + ENABLED: false + ANALYTICS_SOURCE: '' + CLOUD_MESSAGING_ENABLED: false + PROJECT_NUMBER: '' + PROJECT_ID: '' + APPLICATION_ID: '' + API_KEY: '' + +MICROSOFT: + ENABLED: false + CLIENT_ID: 'microsoftClientID' +``` + +Also, all envirenment folders contain a `file_mappings.yaml` file that points to the config files to be parsed. + +By modifying `file_mappings.yaml`, you can achieve splitting of the base `config.yaml` or add additional configuration files. + +Example: + +```yaml +android: + files: + - auth_client.yaml + - config.yaml + - feature_flags.yaml +``` + +## Available Third-Party Services +- **Firebase:** Analytics, Crashlytics, Cloud Messaging +- **Google:** Sign in and Sign up via Google +- **Microsoft:** Sign in and Sign up via Microsoft +- **Facebook:** Sign in and Sign up via Facebook +- **Branch:** Deeplinks +- **Braze:** Could Messaging +- **SegmentIO:** Analytics + +## Available Feature Flags +- **PRE_LOGIN_EXPERIENCE_ENABLED:** Enables the pre login courses discovery experience. +- **WHATS_NEW_ENABLED:** Enables the "What's New" feature to present the latest changes to the user. +- **SOCIAL_AUTH_ENABLED:** Enables SSO buttons on the SignIn and SignUp screens. +- **COURSE_NESTED_LIST_ENABLED:** Enables an alternative visual representation for the course structure. +- **COURSE_BANNER_ENABLED:** Enables the display of the course image on the Course Home screen. +- **COURSE_TOP_TAB_BAR_ENABLED:** Enables an alternative navigation on the Course Home screen. +- **COURSE_UNIT_PROGRESS_ENABLED:** Enables the display of the unit progress within the courseware. + +## Future Support +- To add config related to some other service, create a class, e.g. `ServiceNameConfig.kt`, to be able to populate related fields. +- Create a `function` in the `Config.kt` to be able to parse and use the newly created service from the main Config. + +Example: + +```Kotlin +fun getServiceNameConfig(): ServiceNameConfig { + return getObjectOrNewInstance(SERVICE_NAME_KEY, ServiceNameConfig::class.java) +} +``` + +```yaml +SERVICE_NAME: + ENABLED: false + KEY: '' +``` + +The `default_config` directory is added to the project to provide an idea of how to write config YAML files. diff --git a/README.md b/README.md index 1d4039253..265d49683 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ -# EducationX Android +# Open edX Android -Modern vision of the mobile application for the Open EdX platform from Raccoon Gang. +Modern vision of the mobile application for the Open edX platform from Raccoon Gang. [Documentation](Documentation/Documentation.md) @@ -14,17 +14,16 @@ Modern vision of the mobile application for the Open EdX platform from Raccoon G 3. Choose ``openedx-app-android``. -4. Configure the [config.yaml](default_config/dev/config.yaml) with URLs and OAuth credentials for your Open edX instance. +4. Configure `config_settings.yaml` inside `default_config` and `config.yaml` inside sub direcroties to point to your Open edX configuration. [Configuration Docuementation](./Documentation/ConfigurationManagement.md) 5. Select the build variant ``develop``, ``stage``, or ``prod``. 6. Click the **Run** button. -## API plugin +## API +This project targets on the latest Open edX release and rely on the relevant mobile APIs. -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). +If your platform version is older than December 2023, please follow the instructions to use the [API Plugin](./Documentation/APIs_Compatibility.md). ## License From fe76c3d9838a8e2f75ae00fd51bbbdcbcac9864b Mon Sep 17 00:00:00 2001 From: Igor Date: Thu, 21 Mar 2024 17:32:46 +0100 Subject: [PATCH 22/39] fix: bug when sequence units level appear after tapping on the Back button (#260) --- .../java/org/openedx/app/di/ScreenModule.kt | 1 + .../presentation/dates/CourseDatesFragment.kt | 30 ++++++++++++------- .../dates/CourseDatesViewModel.kt | 4 +++ .../outline/CourseOutlineFragment.kt | 26 +++++++++++----- .../outline/CourseOutlineViewModel.kt | 2 ++ .../dates/CourseDatesViewModelTest.kt | 14 ++++++--- 6 files changed, 55 insertions(+), 22 deletions(-) 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 c74d007a5..a32c35c22 100644 --- a/app/src/main/java/org/openedx/app/di/ScreenModule.kt +++ b/app/src/main/java/org/openedx/app/di/ScreenModule.kt @@ -246,6 +246,7 @@ val screenModule = module { get(), get(), get(), + get(), ) } viewModel { (courseId: String, handoutsType: String) -> diff --git a/course/src/main/java/org/openedx/course/presentation/dates/CourseDatesFragment.kt b/course/src/main/java/org/openedx/course/presentation/dates/CourseDatesFragment.kt index 39a342634..4b0e175c1 100644 --- a/course/src/main/java/org/openedx/course/presentation/dates/CourseDatesFragment.kt +++ b/course/src/main/java/org/openedx/course/presentation/dates/CourseDatesFragment.kt @@ -156,16 +156,26 @@ class CourseDatesFragment : Fragment() { onItemClick = { blockId -> if (blockId.isNotEmpty()) { viewModel.getVerticalBlock(blockId)?.let { verticalBlock -> - viewModel.getSequentialBlock(verticalBlock.id) - ?.let { sequentialBlock -> - router.navigateToCourseSubsections( - fm = requireActivity().supportFragmentManager, - subSectionId = sequentialBlock.id, - courseId = viewModel.courseId, - unitId = verticalBlock.id, - mode = CourseViewMode.FULL - ) - } + if (viewModel.isCourseExpandableSectionsEnabled) { + router.navigateToCourseContainer( + fm = requireActivity().supportFragmentManager, + courseId = viewModel.courseId, + unitId = verticalBlock.id, + componentId = "", + mode = CourseViewMode.FULL + ) + } else { + viewModel.getSequentialBlock(verticalBlock.id) + ?.let { sequentialBlock -> + router.navigateToCourseSubsections( + fm = requireActivity().supportFragmentManager, + subSectionId = sequentialBlock.id, + courseId = viewModel.courseId, + unitId = verticalBlock.id, + mode = CourseViewMode.FULL + ) + } + } } } }, diff --git a/course/src/main/java/org/openedx/course/presentation/dates/CourseDatesViewModel.kt b/course/src/main/java/org/openedx/course/presentation/dates/CourseDatesViewModel.kt index 2380dbab4..6313b599d 100644 --- a/course/src/main/java/org/openedx/course/presentation/dates/CourseDatesViewModel.kt +++ b/course/src/main/java/org/openedx/course/presentation/dates/CourseDatesViewModel.kt @@ -11,6 +11,7 @@ import kotlinx.coroutines.launch import org.openedx.core.BaseViewModel 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.Block import org.openedx.core.extension.getSequentialBlocks @@ -37,6 +38,7 @@ class CourseDatesViewModel( private val networkConnection: NetworkConnection, private val resourceManager: ResourceManager, private val corePreferences: CorePreferences, + private val config: Config, ) : BaseViewModel() { private val _uiState = MutableLiveData(DatesUIState.Loading) @@ -64,6 +66,8 @@ class CourseDatesViewModel( val hasInternetConnection: Boolean get() = networkConnection.isOnline() + val isCourseExpandableSectionsEnabled get() = config.isCourseNestedListEnabled() + init { getCourseDates() viewModelScope.launch { diff --git a/course/src/main/java/org/openedx/course/presentation/outline/CourseOutlineFragment.kt b/course/src/main/java/org/openedx/course/presentation/outline/CourseOutlineFragment.kt index f00055fa9..86fa2314c 100644 --- a/course/src/main/java/org/openedx/course/presentation/outline/CourseOutlineFragment.kt +++ b/course/src/main/java/org/openedx/course/presentation/outline/CourseOutlineFragment.kt @@ -168,14 +168,24 @@ class CourseOutlineFragment : Fragment() { viewModel.resumeSectionBlock?.let { subSection -> viewModel.resumeCourseTappedEvent(subSection.id) viewModel.resumeVerticalBlock?.let { unit -> - router.navigateToCourseSubsections( - requireActivity().supportFragmentManager, - courseId = viewModel.courseId, - subSectionId = subSection.id, - mode = CourseViewMode.FULL, - unitId = unit.id, - componentId = componentId - ) + if (viewModel.isCourseExpandableSectionsEnabled) { + router.navigateToCourseContainer( + fm = requireActivity().supportFragmentManager, + courseId = viewModel.courseId, + unitId = unit.id, + componentId = componentId, + mode = CourseViewMode.FULL + ) + } else { + router.navigateToCourseSubsections( + requireActivity().supportFragmentManager, + courseId = viewModel.courseId, + subSectionId = subSection.id, + mode = CourseViewMode.FULL, + unitId = unit.id, + componentId = componentId + ) + } } } }, diff --git a/course/src/main/java/org/openedx/course/presentation/outline/CourseOutlineViewModel.kt b/course/src/main/java/org/openedx/course/presentation/outline/CourseOutlineViewModel.kt index cf83fd041..0337f811b 100644 --- a/course/src/main/java/org/openedx/course/presentation/outline/CourseOutlineViewModel.kt +++ b/course/src/main/java/org/openedx/course/presentation/outline/CourseOutlineViewModel.kt @@ -73,6 +73,8 @@ class CourseOutlineViewModel( val hasInternetConnection: Boolean get() = networkConnection.isOnline() + val isCourseExpandableSectionsEnabled get() = config.isCourseNestedListEnabled() + private val courseSubSections = mutableMapOf>() private val subSectionsDownloadsCount = mutableMapOf() val courseSubSectionUnit = mutableMapOf() 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 index df7becbc3..752ba30ba 100644 --- a/course/src/test/java/org/openedx/course/presentation/dates/CourseDatesViewModelTest.kt +++ b/course/src/test/java/org/openedx/course/presentation/dates/CourseDatesViewModelTest.kt @@ -21,6 +21,7 @@ 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.model.DateType import org.openedx.core.data.model.User import org.openedx.core.data.storage.CorePreferences @@ -54,6 +55,7 @@ class CourseDatesViewModelTest { private val calendarManager = mockk() private val networkConnection = mockk() private val corePreferences = mockk() + private val config = mockk() private val openEdx = "OpenEdx" private val calendarTitle = "OpenEdx - Abc" @@ -159,7 +161,8 @@ class CourseDatesViewModelTest { calendarManager, networkConnection, resourceManager, - corePreferences + corePreferences, + config ) every { networkConnection.isOnline() } returns true coEvery { interactor.getCourseDates(any()) } throws UnknownHostException() @@ -185,7 +188,8 @@ class CourseDatesViewModelTest { calendarManager, networkConnection, resourceManager, - corePreferences + corePreferences, + config ) every { networkConnection.isOnline() } returns true coEvery { interactor.getCourseDates(any()) } throws Exception() @@ -211,7 +215,8 @@ class CourseDatesViewModelTest { calendarManager, networkConnection, resourceManager, - corePreferences + corePreferences, + config ) every { networkConnection.isOnline() } returns true coEvery { interactor.getCourseDates(any()) } returns mockedCourseDatesResult @@ -236,7 +241,8 @@ class CourseDatesViewModelTest { calendarManager, networkConnection, resourceManager, - corePreferences + corePreferences, + config ) every { networkConnection.isOnline() } returns true coEvery { interactor.getCourseDates(any()) } returns CourseDatesResult( From cc295d9811d6b66ccf9ca66b0c7b84850db4dc60 Mon Sep 17 00:00:00 2001 From: Volodymyr Chekyrta <127732735+volodymyr-chekyrta@users.noreply.github.com> Date: Fri, 22 Mar 2024 13:30:40 +0200 Subject: [PATCH 23/39] fix: Android Studio Iguana build issues (#265) --- .../signup/RegistrationScreenTest.kt | 165 ------------------ build.gradle | 4 +- buildSrc/build.gradle | 9 + .../detail/CourseDetailsScreenTest.kt | 134 -------------- .../outline/CourseOutlineScreenTest.kt | 104 ----------- .../presentation/DiscoveryScreenTest.kt | 152 ---------------- settings.gradle | 5 + 7 files changed, 16 insertions(+), 557 deletions(-) delete mode 100644 auth/src/androidTest/java/org/openedx/auth/presentation/signup/RegistrationScreenTest.kt delete mode 100644 course/src/androidTest/java/org/openedx/course/presentation/detail/CourseDetailsScreenTest.kt delete mode 100644 course/src/androidTest/java/org/openedx/course/presentation/outline/CourseOutlineScreenTest.kt delete mode 100644 discovery/src/androidTest/java/org/openedx/discovery/presentation/DiscoveryScreenTest.kt diff --git a/auth/src/androidTest/java/org/openedx/auth/presentation/signup/RegistrationScreenTest.kt b/auth/src/androidTest/java/org/openedx/auth/presentation/signup/RegistrationScreenTest.kt deleted file mode 100644 index 845e39034..000000000 --- a/auth/src/androidTest/java/org/openedx/auth/presentation/signup/RegistrationScreenTest.kt +++ /dev/null @@ -1,165 +0,0 @@ -package org.openedx.auth.presentation.signup - -import androidx.activity.ComponentActivity -import androidx.compose.ui.semantics.ProgressBarRangeInfo -import androidx.compose.ui.test.* -import androidx.compose.ui.test.junit4.createAndroidComposeRule -import org.openedx.auth.R -import org.openedx.core.domain.model.RegistrationField -import org.openedx.core.domain.model.RegistrationFieldType -import org.openedx.core.ui.WindowSize -import org.openedx.core.ui.WindowType -import org.junit.Rule -import org.junit.Test - -class RegistrationScreenTest { - - @get:Rule - val composeTestRule = createAndroidComposeRule() - - //region mockField - private val option = RegistrationField.Option("def", "Bachelor", "Android") - - private val mockField = RegistrationField( - "Fullname", - "Fullname", - RegistrationFieldType.TEXT, - "Fullname", - instructions = "Enter your fullname", - exposed = false, - required = true, - restrictions = RegistrationField.Restrictions(), - options = listOf(option, option), - errorInstructions = "" - ) - //endregion - - - @Test - fun signUpLoadingFields() { - composeTestRule.setContent { - org.openedx.auth.presentation.signup.RegistrationScreen( - windowSize = WindowSize(WindowType.Compact, WindowType.Compact), - uiState = org.openedx.auth.presentation.signup.SignUpUIState.Loading, - uiMessage = null, - isButtonClicked = false, - validationError = false, - onBackClick = {}, - onRegisterClick = {} - ) - } - with(composeTestRule) { - onRoot().printToLog("ROOT_TAG") - - onNode(hasProgressBarRangeInfo(ProgressBarRangeInfo(0f, 0f..0f))).assertExists() - onNode(hasScrollAction().and(hasAnyChild(hasText(activity.getString(R.string.auth_sign_up))))).assertDoesNotExist() - } - } - - @Test - fun signUpNoOptionalFields() { - composeTestRule.setContent { - org.openedx.auth.presentation.signup.RegistrationScreen( - windowSize = WindowSize(WindowType.Compact, WindowType.Compact), - uiState = org.openedx.auth.presentation.signup.SignUpUIState.Fields( - fields = listOf( - mockField, - mockField.copy(name = "Age", label = "Age", errorInstructions = "error") - ), - optionalFields = emptyList() - ), - uiMessage = null, - isButtonClicked = false, - validationError = false, - onBackClick = {}, - onRegisterClick = {} - ) - } - with(composeTestRule) { - onNode(hasText("Fullname").and(hasSetTextAction())).assertExists() - - onNode(hasText("Age").and(hasSetTextAction())).assertExists() - - onNodeWithText(activity.getString(R.string.auth_show_optional_fields)).assertDoesNotExist() - - onNode(hasText(activity.getString(R.string.auth_create_account)).and(hasClickAction())).assertExists() - } - } - - @Test - fun signUpHasOptionalFields() { - composeTestRule.setContent { - org.openedx.auth.presentation.signup.RegistrationScreen( - windowSize = WindowSize(WindowType.Compact, WindowType.Compact), - uiState = org.openedx.auth.presentation.signup.SignUpUIState.Fields( - fields = listOf( - mockField, - mockField.copy(name = "Age", label = "Age", errorInstructions = "error") - ), - optionalFields = listOf(mockField) - ), - uiMessage = null, - isButtonClicked = false, - validationError = false, - onBackClick = {}, - onRegisterClick = {} - ) - } - with(composeTestRule) { - onNode(hasText("Age").and(hasSetTextAction())).assertExists() - - onNodeWithText(activity.getString(R.string.auth_show_optional_fields)).assertExists() - - onNode(hasText(activity.getString(R.string.auth_create_account)).and(hasClickAction())).assertExists() - } - } - - @Test - fun signUpFieldsWithError() { - composeTestRule.setContent { - org.openedx.auth.presentation.signup.RegistrationScreen( - windowSize = WindowSize(WindowType.Compact, WindowType.Compact), - uiState = org.openedx.auth.presentation.signup.SignUpUIState.Fields( - fields = listOf( - mockField, - mockField.copy(name = "Age", label = "Age", errorInstructions = "error") - ), - optionalFields = emptyList() - ), - uiMessage = null, - isButtonClicked = false, - validationError = false, - onBackClick = {}, - onRegisterClick = {} - ) - } - with(composeTestRule) { - onNode(hasText("error")).assertExists() - } - } - - @Test - fun signUpCreateAccountClicked() { - composeTestRule.setContent { - org.openedx.auth.presentation.signup.RegistrationScreen( - windowSize = WindowSize(WindowType.Compact, WindowType.Compact), - uiState = org.openedx.auth.presentation.signup.SignUpUIState.Fields( - fields = listOf( - mockField, - mockField.copy(name = "Age", label = "Age", errorInstructions = "error") - ), - optionalFields = listOf(mockField) - ), - uiMessage = null, - isButtonClicked = true, - validationError = false, - onBackClick = {}, - onRegisterClick = {} - ) - } - with(composeTestRule) { - onNode(hasProgressBarRangeInfo(ProgressBarRangeInfo(0f, 0f..0f))).assertExists() - onNode(hasText(activity.getString(R.string.auth_create_account)).and(hasClickAction())).assertDoesNotExist() - } - } -} \ No newline at end of file diff --git a/build.gradle b/build.gradle index 8e61cf92d..6ebb49a56 100644 --- a/build.gradle +++ b/build.gradle @@ -20,8 +20,8 @@ plugins { id "com.google.firebase.crashlytics" version "2.9.6" apply false } -task clean(type: Delete) { - delete rootProject.buildDir +tasks.register('clean', Delete) { + delete rootProject.layout.buildDirectory } ext { diff --git a/buildSrc/build.gradle b/buildSrc/build.gradle index 9cc60ef40..f1d8de5cb 100644 --- a/buildSrc/build.gradle +++ b/buildSrc/build.gradle @@ -1,7 +1,16 @@ +plugins { + id 'java-library' +} + repositories { mavenCentral() } +java { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 +} + dependencies { implementation localGroovy() implementation gradleApi() diff --git a/course/src/androidTest/java/org/openedx/course/presentation/detail/CourseDetailsScreenTest.kt b/course/src/androidTest/java/org/openedx/course/presentation/detail/CourseDetailsScreenTest.kt deleted file mode 100644 index 5d79f2074..000000000 --- a/course/src/androidTest/java/org/openedx/course/presentation/detail/CourseDetailsScreenTest.kt +++ /dev/null @@ -1,134 +0,0 @@ -package org.openedx.course.presentation.detail - -import androidx.activity.ComponentActivity -import androidx.compose.ui.semantics.ProgressBarRangeInfo -import androidx.compose.ui.test.* -import androidx.compose.ui.test.junit4.createAndroidComposeRule -import org.openedx.core.domain.model.* -import org.openedx.course.R -import org.junit.Rule -import org.junit.Test - -class CourseDetailsScreenTest { - - @get:Rule - val composeTestRule = createAndroidComposeRule() - - //region course - - private val course = Course( - id = "id", - blocksUrl = "blocksUrl", - courseId = "courseId", - effort = "effort", - enrollmentStart = "enrollmentStart", - enrollmentEnd = "enrollmentEnd", - hidden = false, - invitationOnly = false, - media = Media(), - mobileAvailable = true, - name = "Test course", - number = "number", - org = "EdX", - pacing = "pacing", - shortDescription = "shortDescription", - start = "start", - end = "end", - startDisplay = "startDisplay", - startType = "startType", - overview = "" - ) - - private val enrolledCourse = EnrolledCourse( - auditAccessExpires = "", - created = "created", - certificate = Certificate(""), - mode = "mode", - isActive = true, - course = EnrolledCourseData( - id = "id", - name = "name", - number = "", - org = "Org", - start = "", - startDisplay = "", - startType = "", - end = "Ending in 22 November", - dynamicUpgradeDeadline = "", - subscriptionId = "", - coursewareAccess = CoursewareAccess( - true, - "", - "", - "", - "", - "" - ), - media = null, - courseImage = "", - courseAbout = "", - courseSharingUtmParameters = CourseSharingUtmParameters("", ""), - courseUpdates = "", - courseHandouts = "", - discussionUrl = "", - videoOutline = "", - isSelfPaced = false - ) - ) - - //endregion - - @Test - fun courseDetailsScreenLoading() { - composeTestRule.setContent { - CourseDetailsScreen( - uiState = CourseDetailsUIState.Loading, - uiMessage = null, - htmlBody = "", - onBackClick = {}, - onButtonClick = {} - ) - } - - with(composeTestRule) { - onNode( - hasProgressBarRangeInfo( - ProgressBarRangeInfo( - current = 0f, - range = 0f..0f, - steps = 0 - ) - ) - ).assertExists() - - onNode( - hasClickAction() and hasTextExactly( - activity.getString(R.string.course_view_course), - activity.getString(R.string.course_register_now) - ) - ).assertDoesNotExist() - } - } - - @Test - fun courseDetailsScreenLoaded() { - composeTestRule.setContent { - CourseDetailsScreen( - uiState = CourseDetailsUIState.CourseData(course, enrolledCourse), - uiMessage = null, - htmlBody = "", - onBackClick = {}, - onButtonClick = {} - ) - } - - with(composeTestRule) { - onNode( - hasClickAction() and hasText( - activity.getString(R.string.course_view_course), - ignoreCase = true - ) - ).assertExists() - } - } -} \ No newline at end of file diff --git a/course/src/androidTest/java/org/openedx/course/presentation/outline/CourseOutlineScreenTest.kt b/course/src/androidTest/java/org/openedx/course/presentation/outline/CourseOutlineScreenTest.kt deleted file mode 100644 index dd1ecce8f..000000000 --- a/course/src/androidTest/java/org/openedx/course/presentation/outline/CourseOutlineScreenTest.kt +++ /dev/null @@ -1,104 +0,0 @@ -package org.openedx.course.presentation.outline - -import androidx.activity.ComponentActivity -import androidx.compose.ui.semantics.ProgressBarRangeInfo -import androidx.compose.ui.test.* -import androidx.compose.ui.test.junit4.createAndroidComposeRule -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.Certificate -import org.openedx.course.R -import org.junit.Rule -import org.junit.Test - -class CourseOutlineScreenTest { - - @get:Rule - val composeTestRule = createAndroidComposeRule() - - //region block - private val mockBlock = Block( - id = "id", - blockId = "blockId", - lmsWebUrl = "lmsWebUrl", - legacyWebUrl = "legacyWebUrl", - studentViewUrl = "studentViewUrl", - type = BlockType.HTML, - displayName = "Block", - graded = false, - studentViewData = null, - studentViewMultiDevice = false, - blockCounts = BlockCounts(0), - descendants = emptyList(), - completion = 0.0 - ) - //endregion - - @Test - fun courseOutlineLoading() { - composeTestRule.setContent { - CourseOutlineScreen( - uiState = CourseOutlineUIState.Loading, - courseTitle = "Title", - courseImage = "", - courseCertificate = Certificate(""), - uiMessage = null, - refreshing = false, - onSwipeRefresh = {}, - onItemClick = {}, - onResumeClick = {}, - onBackClick = {} - ) - } - - with(composeTestRule) { - onNode( - hasProgressBarRangeInfo( - ProgressBarRangeInfo( - current = 0f, - range = 0f..0f, - steps = 0 - ) - ) - ).assertExists() - - onNode(hasText(activity.getString(R.string.course_content))).assertDoesNotExist() - } - } - - - @Test - fun courseOutlineLoaded() { - composeTestRule.setContent { - CourseOutlineScreen( - uiState = CourseOutlineUIState.CourseData(listOf(mockBlock, mockBlock), null), - courseTitle = "Title", - courseImage = "", - courseCertificate = Certificate(""), - uiMessage = null, - refreshing = false, - onSwipeRefresh = {}, - onItemClick = {}, - onResumeClick = {}, - onBackClick = {} - ) - } - - with(composeTestRule) { - onNode( - hasProgressBarRangeInfo( - ProgressBarRangeInfo( - current = 0f, - range = 0f..0f, - steps = 0 - ) - ) - ).assertDoesNotExist() - - onNode(hasScrollAction() and hasAnyChild(hasText(mockBlock.displayName))).assertExists() - onNode(hasScrollAction()).onChildren().assertCountEquals(3) - onNode(hasText(activity.getString(R.string.course_content))).assertExists() - } - } -} \ 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 deleted file mode 100644 index 6a70f334b..000000000 --- a/discovery/src/androidTest/java/org/openedx/discovery/presentation/DiscoveryScreenTest.kt +++ /dev/null @@ -1,152 +0,0 @@ -package org.openedx.discovery.presentation - -import androidx.activity.ComponentActivity -import androidx.compose.ui.semantics.ProgressBarRangeInfo -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 java.util.Date - -class DiscoveryScreenTest { - - @get:Rule - val composeTestRule = createAndroidComposeRule() - - //region mockCourse - private val course = Course( - id = "id", - blocksUrl = "blocksUrl", - courseId = "courseId", - effort = "effort", - enrollmentStart = Date(), - enrollmentEnd = null, - hidden = false, - invitationOnly = false, - media = Media(), - mobileAvailable = true, - name = "Test course", - number = "number", - org = "EdX", - pacing = "pacing", - shortDescription = "shortDescription", - start = "start", - end = "end", - startDisplay = "startDisplay", - startType = "startType", - overview = "", - isEnrolled = false - ) - //endregion - - @Test - fun discoveryScreenLoading() { - composeTestRule.setContent { - DiscoveryScreen( - windowSize = WindowSize(WindowType.Compact, WindowType.Compact), - state = DiscoveryUIState.Loading, - uiMessage = null, - canLoadMore = false, - refreshing = false, - hasInternetConnection = true, - canShowBackButton = false, - appUpgradeParameters = AppUpdateState.AppUpgradeParameters(), - onSearchClick = {}, - onSwipeRefresh = {}, - paginationCallback = {}, - onItemClick = {}, - onReloadClick = {}, - onBackClick = {}, - ) - } - - with(composeTestRule) { - onNode( - hasProgressBarRangeInfo( - ProgressBarRangeInfo( - current = 0f, - range = 0f..0f, - steps = 0 - ) - ) - ) - } - - } - - @Test - fun discoveryScreenLoaded() { - composeTestRule.setContent { - DiscoveryScreen( - windowSize = WindowSize(WindowType.Compact, WindowType.Compact), - state = DiscoveryUIState.Courses(listOf(course)), - uiMessage = null, - canLoadMore = false, - refreshing = false, - hasInternetConnection = true, - canShowBackButton = false, - appUpgradeParameters = AppUpdateState.AppUpgradeParameters(), - onSearchClick = {}, - onSwipeRefresh = {}, - paginationCallback = {}, - onItemClick = {}, - onReloadClick = {}, - onBackClick = {}, - ) - } - - with(composeTestRule) { - onNode(hasScrollAction()).onChildren().assertAny(hasText(course.name)) - } - } - - @Test - fun discoveryScreenPaginationAvailable() { - composeTestRule.setContent { - DiscoveryScreen( - windowSize = WindowSize(WindowType.Compact, WindowType.Compact), - state = DiscoveryUIState.Courses(listOf(course)), - uiMessage = null, - canLoadMore = true, - refreshing = false, - hasInternetConnection = true, - canShowBackButton = false, - appUpgradeParameters = AppUpdateState.AppUpgradeParameters(), - onSearchClick = {}, - onSwipeRefresh = {}, - paginationCallback = {}, - onItemClick = {}, - onReloadClick = {}, - onBackClick = {}, - ) - } - - with(composeTestRule) { - onNode(hasScrollAction()).onChildren().assertAny(hasText(course.name)) - onNode( - hasScrollAction().and( - hasAnyChild( - hasProgressBarRangeInfo( - ProgressBarRangeInfo( - current = 0f, - range = 0f..0f, - steps = 0 - ) - ) - ) - ) - ) - } - - } -} \ No newline at end of file diff --git a/settings.gradle b/settings.gradle index 205f840ee..66cb04c11 100644 --- a/settings.gradle +++ b/settings.gradle @@ -32,6 +32,11 @@ dependencyResolutionManagement { maven { url "https://pkgs.dev.azure.com/MicrosoftDeviceSDK/DuoSDK-Public/_packaging/Duo-SDK-Feed/maven/v1" } } } +//Workaround for AS Iguana https://github.com/gradle/gradle/issues/28407 +//When Android Studio Iguana or later attempts to rebuild the project, the build fails with: +//Unable to make progress running work. There are items queued for execution but none of them can be started +gradle.startParameter.excludedTaskNames.addAll([":buildSrc:testClasses"]) + rootProject.name = "OpenEdX" include ':app' include ':core' From f49283d3d5ab4aa764ec39a96cc169b6e00f0a8b Mon Sep 17 00:00:00 2001 From: Igor Date: Mon, 25 Mar 2024 10:36:19 +0100 Subject: [PATCH 24/39] fix: bug when the name of subcategory is empty (#263) --- .../java/org/openedx/app/di/ScreenModule.kt | 2 +- .../data/repository/DiscussionRepository.kt | 20 ++++++++-- .../discussion/domain/model/TopicsData.kt | 1 + .../presentation/ui/DiscussionUI.kt | 40 +++++++++++++++---- discussion/src/main/res/values/strings.xml | 1 + 5 files changed, 53 insertions(+), 11 deletions(-) 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 a32c35c22..8c0add1c3 100644 --- a/app/src/main/java/org/openedx/app/di/ScreenModule.kt +++ b/app/src/main/java/org/openedx/app/di/ScreenModule.kt @@ -260,7 +260,7 @@ val screenModule = module { viewModel { CourseSearchViewModel(get(), get(), get(), get(), get()) } viewModel { SelectDialogViewModel(get()) } - single { DiscussionRepository(get(), get()) } + single { DiscussionRepository(get(), get(), get()) } factory { DiscussionInteractor(get()) } viewModel { (courseId: String) -> DiscussionTopicsViewModel(get(), get(), get(), courseId) } viewModel { (courseId: String, topicId: String, threadType: String) -> diff --git a/discussion/src/main/java/org/openedx/discussion/data/repository/DiscussionRepository.kt b/discussion/src/main/java/org/openedx/discussion/data/repository/DiscussionRepository.kt index b95b5447d..4ca6cde8d 100644 --- a/discussion/src/main/java/org/openedx/discussion/data/repository/DiscussionRepository.kt +++ b/discussion/src/main/java/org/openedx/discussion/data/repository/DiscussionRepository.kt @@ -2,22 +2,31 @@ package org.openedx.discussion.data.repository import org.openedx.core.data.model.BlocksCompletionBody import org.openedx.core.data.storage.CorePreferences +import org.openedx.core.system.ResourceManager +import org.openedx.discussion.R import org.openedx.discussion.data.api.DiscussionApi -import org.openedx.discussion.data.model.request.* +import org.openedx.discussion.data.model.request.CommentBody +import org.openedx.discussion.data.model.request.FollowBody +import org.openedx.discussion.data.model.request.ReadBody +import org.openedx.discussion.data.model.request.ReportBody +import org.openedx.discussion.data.model.request.ThreadBody +import org.openedx.discussion.data.model.request.VoteBody import org.openedx.discussion.domain.model.CommentsData import org.openedx.discussion.domain.model.ThreadsData import org.openedx.discussion.domain.model.Topic class DiscussionRepository( private val api: DiscussionApi, - private val preferencesManager: CorePreferences - ) { + private val preferencesManager: CorePreferences, + private val resourceManager: ResourceManager +) { private val topics = mutableListOf() private var currentCourseId = "" suspend fun getCourseTopics(courseId: String): List { val topicsData = api.getCourseTopics(courseId).mapToDomain() + val defaultTopicName = resourceManager.getString(R.string.discussion_unnamed_subcategory) currentCourseId = courseId topics.clear() topics.addAll(topicsData.nonCoursewareTopics) @@ -27,6 +36,11 @@ class DiscussionRepository( topics.addAll(it.children) } } + topics.forEachIndexed { index, topic -> + if (topic.name.isBlank()) { + topics[index] = topic.copy(name = defaultTopicName) + } + } return topics.toList() } diff --git a/discussion/src/main/java/org/openedx/discussion/domain/model/TopicsData.kt b/discussion/src/main/java/org/openedx/discussion/domain/model/TopicsData.kt index 6a3f5c5d5..12d9ad926 100644 --- a/discussion/src/main/java/org/openedx/discussion/domain/model/TopicsData.kt +++ b/discussion/src/main/java/org/openedx/discussion/domain/model/TopicsData.kt @@ -1,5 +1,6 @@ package org.openedx.discussion.domain.model + data class TopicsData( val coursewareTopics: List, val nonCoursewareTopics: List 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 285b01d66..8d6323bfd 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 @@ -8,9 +8,24 @@ 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.* +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.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.shape.CircleShape -import androidx.compose.material.* +import androidx.compose.material.Card +import androidx.compose.material.Divider +import androidx.compose.material.Icon +import androidx.compose.material.MaterialTheme +import androidx.compose.material.Surface +import androidx.compose.material.Text import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.ChevronRight import androidx.compose.material.icons.outlined.HelpOutline @@ -94,7 +109,10 @@ 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 = stringResource(id = org.openedx.core.R.string.core_accessibility_user_profile_image, thread.author), + 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) @@ -245,7 +263,10 @@ 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 = stringResource(id = org.openedx.core.R.string.core_accessibility_user_profile_image, comment.author), + contentDescription = stringResource( + id = org.openedx.core.R.string.core_accessibility_user_profile_image, + comment.author + ), modifier = Modifier .size(32.dp) .clip(CircleShape) @@ -395,7 +416,10 @@ 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 = stringResource(id = org.openedx.core.R.string.core_accessibility_user_profile_image, comment.author), + contentDescription = stringResource( + id = org.openedx.core.R.string.core_accessibility_user_profile_image, + comment.author + ), modifier = Modifier .size(32.dp) .clip(CircleShape) @@ -504,7 +528,8 @@ fun ThreadItem( text = textType, painter = icon, color = MaterialTheme.appColors.textPrimaryVariant, - textStyle = MaterialTheme.appTypography.labelSmall) + textStyle = MaterialTheme.appTypography.labelSmall + ) if (thread.unreadCommentCount > 0 && !thread.read) { Row( modifier = Modifier, @@ -562,7 +587,8 @@ fun ThreadItem( ), painter = painterResource(id = R.drawable.discussion_ic_responses), color = MaterialTheme.appColors.textAccent, - textStyle = MaterialTheme.appTypography.labelLarge) + textStyle = MaterialTheme.appTypography.labelLarge + ) } } diff --git a/discussion/src/main/res/values/strings.xml b/discussion/src/main/res/values/strings.xml index 8d66fc99a..cf50a9dbe 100644 --- a/discussion/src/main/res/values/strings.xml +++ b/discussion/src/main/res/values/strings.xml @@ -34,6 +34,7 @@ anonymous No discussions yet Click the button below to create your first discussion. + Unnamed subcategory From f13bd4749c2c029e1e1134500a1100a7115c2413 Mon Sep 17 00:00:00 2001 From: Volodymyr Chekyrta <127732735+volodymyr-chekyrta@users.noreply.github.com> Date: Tue, 26 Mar 2024 08:40:53 +0200 Subject: [PATCH 25/39] fix: Toolbar alignment (#268) --- core/src/main/java/org/openedx/core/ui/ComposeCommon.kt | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) 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 afa8d7b12..7114426c1 100644 --- a/core/src/main/java/org/openedx/core/ui/ComposeCommon.kt +++ b/core/src/main/java/org/openedx/core/ui/ComposeCommon.kt @@ -152,7 +152,7 @@ fun Toolbar( canShowBackBtn: Boolean = false, onBackClick: () -> Unit = {} ) { - Row( + Box( modifier = modifier .fillMaxWidth() .height(48.dp), @@ -163,9 +163,10 @@ fun Toolbar( Text( modifier = Modifier + .fillMaxWidth() .testTag("txt_toolbar_title") - .align(Alignment.CenterVertically) - .padding(end = 16.dp), + .align(Alignment.Center) + .padding(start = 48.dp, end = 48.dp), text = label, color = MaterialTheme.appColors.textPrimary, style = MaterialTheme.appTypography.titleMedium, From 1930739048a906774d67f31b97a0ab310088de8b Mon Sep 17 00:00:00 2001 From: Farhan Arshad <43750646+farhan-arshad-dev@users.noreply.github.com> Date: Tue, 26 Mar 2024 14:50:34 +0500 Subject: [PATCH 26/39] style: Dates Tab Shift Due Dates Success SnackBar Design Changes (#264) style: Dates Tab Shift Due Dates Success SnackBar Design Changes fixes: LEARNER-9868 --- .../main/java/org/openedx/core/UIMessage.kt | 4 +- core/src/main/res/values/strings.xml | 3 +- .../openedx/course/DatesShiftedSnackBar.kt | 5 ++ .../presentation/dates/CourseDatesFragment.kt | 26 +++++++ .../dates/CourseDatesViewModel.kt | 5 +- .../outline/CourseOutlineFragment.kt | 63 +++++++++-------- .../outline/CourseOutlineViewModel.kt | 4 +- .../course/presentation/ui/CourseUI.kt | 70 ++++++++++++++++--- course/src/main/res/values/strings.xml | 1 + 9 files changed, 140 insertions(+), 41 deletions(-) create mode 100644 course/src/main/java/org/openedx/course/DatesShiftedSnackBar.kt diff --git a/core/src/main/java/org/openedx/core/UIMessage.kt b/core/src/main/java/org/openedx/core/UIMessage.kt index 6f26d6f65..8a9267f36 100644 --- a/core/src/main/java/org/openedx/core/UIMessage.kt +++ b/core/src/main/java/org/openedx/core/UIMessage.kt @@ -2,11 +2,11 @@ package org.openedx.core import androidx.compose.material.SnackbarDuration -sealed class UIMessage { +open class UIMessage { class SnackBarMessage( val message: String, val duration: SnackbarDuration = SnackbarDuration.Long, ) : UIMessage() class ToastMessage(val message: String) : UIMessage() -} \ No newline at end of file +} diff --git a/core/src/main/res/values/strings.xml b/core/src/main/res/values/strings.xml index 10ed72367..fdeefc411 100644 --- a/core/src/main/res/values/strings.xml +++ b/core/src/main/res/values/strings.xml @@ -106,9 +106,10 @@ 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. + Due Dates Shifted 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 + View All Dates Register Sign in diff --git a/course/src/main/java/org/openedx/course/DatesShiftedSnackBar.kt b/course/src/main/java/org/openedx/course/DatesShiftedSnackBar.kt new file mode 100644 index 000000000..fd2a3ce6b --- /dev/null +++ b/course/src/main/java/org/openedx/course/DatesShiftedSnackBar.kt @@ -0,0 +1,5 @@ +package org.openedx.course + +import org.openedx.core.UIMessage + +class DatesShiftedSnackBar : UIMessage() diff --git a/course/src/main/java/org/openedx/course/presentation/dates/CourseDatesFragment.kt b/course/src/main/java/org/openedx/course/presentation/dates/CourseDatesFragment.kt index 4b0e175c1..e906ef6a2 100644 --- a/course/src/main/java/org/openedx/course/presentation/dates/CourseDatesFragment.kt +++ b/course/src/main/java/org/openedx/course/presentation/dates/CourseDatesFragment.kt @@ -37,6 +37,10 @@ import androidx.compose.material.ExperimentalMaterialApi import androidx.compose.material.Icon import androidx.compose.material.MaterialTheme import androidx.compose.material.Scaffold +import androidx.compose.material.SnackbarData +import androidx.compose.material.SnackbarDuration +import androidx.compose.material.SnackbarHost +import androidx.compose.material.SnackbarHostState import androidx.compose.material.Surface import androidx.compose.material.Switch import androidx.compose.material.SwitchDefaults @@ -49,6 +53,7 @@ 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.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.livedata.observeAsState @@ -99,12 +104,14 @@ import org.openedx.core.ui.theme.appTypography import org.openedx.core.ui.windowSizeValue import org.openedx.core.utils.TimeUtils import org.openedx.core.utils.clearTime +import org.openedx.course.DatesShiftedSnackBar import org.openedx.course.R import org.openedx.course.presentation.CourseRouter import org.openedx.course.presentation.calendarsync.CalendarSyncUIState import org.openedx.course.presentation.container.CourseContainerFragment import org.openedx.course.presentation.ui.CourseDatesBanner import org.openedx.course.presentation.ui.CourseDatesBannerTablet +import org.openedx.course.presentation.ui.DatesShiftedSnackBar import java.util.concurrent.atomic.AtomicReference import org.openedx.core.R as coreR @@ -268,6 +275,16 @@ internal fun CourseDatesScreen( ) } + val snackState = remember { SnackbarHostState() } + if (uiMessage is DatesShiftedSnackBar) { + val datesShiftedMessage = stringResource(id = R.string.course_dates_shifted_message) + LaunchedEffect(uiMessage) { + snackState.showSnackbar( + message = datesShiftedMessage, + duration = SnackbarDuration.Long + ) + } + } HandleUIMessage(uiMessage = uiMessage, scaffoldState = scaffoldState) Box( @@ -394,6 +411,15 @@ internal fun CourseDatesScreen( }) } } + + SnackbarHost( + modifier = Modifier.align(Alignment.BottomStart), + hostState = snackState + ) { snackbarData: SnackbarData -> + DatesShiftedSnackBar(onClose = { + snackbarData.dismiss() + }) + } } } } diff --git a/course/src/main/java/org/openedx/course/presentation/dates/CourseDatesViewModel.kt b/course/src/main/java/org/openedx/course/presentation/dates/CourseDatesViewModel.kt index 6313b599d..0c1754552 100644 --- a/course/src/main/java/org/openedx/course/presentation/dates/CourseDatesViewModel.kt +++ b/course/src/main/java/org/openedx/course/presentation/dates/CourseDatesViewModel.kt @@ -9,6 +9,7 @@ import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.update 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 @@ -22,6 +23,7 @@ import org.openedx.core.system.connection.NetworkConnection import org.openedx.core.system.notifier.CalendarSyncEvent.CheckCalendarSyncEvent import org.openedx.core.system.notifier.CalendarSyncEvent.CreateCalendarSyncEvent import org.openedx.core.system.notifier.CourseNotifier +import org.openedx.course.DatesShiftedSnackBar import org.openedx.course.domain.interactor.CourseInteractor import org.openedx.course.presentation.calendarsync.CalendarManager import org.openedx.course.presentation.calendarsync.CalendarSyncDialogType @@ -115,6 +117,7 @@ class CourseDatesViewModel( try { interactor.resetCourseDates(courseId = courseId) getCourseDates() + _uiMessage.value = DatesShiftedSnackBar() onResetDates(true) } catch (e: Exception) { if (e.isInternetError()) { @@ -122,7 +125,7 @@ class CourseDatesViewModel( UIMessage.SnackBarMessage(resourceManager.getString(CoreR.string.core_error_no_connection)) } else { _uiMessage.value = - UIMessage.SnackBarMessage(resourceManager.getString(CoreR.string.core_error_unknown_error)) + UIMessage.SnackBarMessage(resourceManager.getString(R.string.core_dates_shift_dates_unsuccessful_msg)) } onResetDates(false) } diff --git a/course/src/main/java/org/openedx/course/presentation/outline/CourseOutlineFragment.kt b/course/src/main/java/org/openedx/course/presentation/outline/CourseOutlineFragment.kt index 86fa2314c..c55d622cf 100644 --- a/course/src/main/java/org/openedx/course/presentation/outline/CourseOutlineFragment.kt +++ b/course/src/main/java/org/openedx/course/presentation/outline/CourseOutlineFragment.kt @@ -28,6 +28,10 @@ import androidx.compose.material.ExperimentalMaterialApi import androidx.compose.material.Icon import androidx.compose.material.MaterialTheme import androidx.compose.material.Scaffold +import androidx.compose.material.SnackbarData +import androidx.compose.material.SnackbarDuration +import androidx.compose.material.SnackbarHost +import androidx.compose.material.SnackbarHostState import androidx.compose.material.Surface import androidx.compose.material.Text import androidx.compose.material.pullrefresh.PullRefreshIndicator @@ -35,6 +39,7 @@ 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.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.livedata.observeAsState import androidx.compose.runtime.mutableStateOf @@ -54,7 +59,6 @@ import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import androidx.core.os.bundleOf import androidx.fragment.app.Fragment -import com.google.android.material.snackbar.Snackbar import org.koin.android.ext.android.inject import org.koin.androidx.viewmodel.ext.android.viewModel import org.koin.core.parameter.parametersOf @@ -79,6 +83,7 @@ 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.course.DatesShiftedSnackBar import org.openedx.course.presentation.CourseRouter import org.openedx.course.presentation.container.CourseContainerFragment import org.openedx.course.presentation.container.CourseContainerTab @@ -89,6 +94,7 @@ import org.openedx.course.presentation.ui.CourseExpandableChapterCard import org.openedx.course.presentation.ui.CourseImageHeader import org.openedx.course.presentation.ui.CourseSectionCard import org.openedx.course.presentation.ui.CourseSubSectionItem +import org.openedx.course.presentation.ui.DatesShiftedSnackBar import java.io.File import java.util.Date @@ -99,8 +105,6 @@ class CourseOutlineFragment : Fragment() { } private val router by inject() - private var snackBar: Snackbar? = null - override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) lifecycle.addObserver(viewModel) @@ -210,38 +214,16 @@ class CourseOutlineFragment : Fragment() { onResetDatesClick = { viewModel.resetCourseDatesBanner(onResetDates = { (parentFragment as CourseContainerFragment).updateCourseDates() - showDatesUpdateSnackbar(it) }) - } - ) - } - } - } - - override fun onDestroyView() { - snackBar?.dismiss() - super.onDestroyView() - } - - private fun showDatesUpdateSnackbar(isSuccess: Boolean) { - val message = if (isSuccess) { - getString(R.string.core_dates_shift_dates_successfully_msg) - } else { - getString(R.string.core_dates_shift_dates_unsuccessful_msg) - } - snackBar = view?.let { - Snackbar.make(it, message, Snackbar.LENGTH_LONG).apply { - if (isSuccess) { - setAction(R.string.core_dates_view_all_dates) { + }, + onViewDates = { (parentFragment as CourseContainerFragment).navigateToTab(CourseContainerTab.DATES) } - } + ) } } - snackBar?.show() } - companion object { private const val ARG_COURSE_ID = "courseId" private const val ARG_TITLE = "title" @@ -288,6 +270,7 @@ internal fun CourseOutlineScreen( onResumeClick: (String) -> Unit, onDownloadClick: (Block) -> Unit, onResetDatesClick: () -> Unit, + onViewDates: () -> Unit?, ) { val scaffoldState = rememberScaffoldState() val pullRefreshState = @@ -331,6 +314,17 @@ internal fun CourseOutlineScreen( ) } + val snackState = remember { SnackbarHostState() } + if (uiMessage is DatesShiftedSnackBar) { + val datesShiftedMessage = + stringResource(id = org.openedx.course.R.string.course_dates_shifted_message) + LaunchedEffect(uiMessage) { + snackState.showSnackbar( + message = datesShiftedMessage, + duration = SnackbarDuration.Long + ) + } + } HandleUIMessage(uiMessage = uiMessage, scaffoldState = scaffoldState) Box( @@ -512,6 +506,17 @@ internal fun CourseOutlineScreen( ) } } + + SnackbarHost( + modifier = Modifier.align(Alignment.BottomStart), + hostState = snackState + ) { snackbarData: SnackbarData -> + DatesShiftedSnackBar(showAction = true, + onViewDates = onViewDates, + onClose = { + snackbarData.dismiss() + }) + } } } } @@ -661,6 +666,7 @@ private fun CourseOutlineScreenPreview() { onReloadClick = {}, onDownloadClick = {}, onResetDatesClick = {}, + onViewDates = {}, ) } } @@ -701,6 +707,7 @@ private fun CourseOutlineScreenTabletPreview() { onReloadClick = {}, onDownloadClick = {}, onResetDatesClick = {}, + onViewDates = {}, ) } } diff --git a/course/src/main/java/org/openedx/course/presentation/outline/CourseOutlineViewModel.kt b/course/src/main/java/org/openedx/course/presentation/outline/CourseOutlineViewModel.kt index 0337f811b..b129fcd13 100644 --- a/course/src/main/java/org/openedx/course/presentation/outline/CourseOutlineViewModel.kt +++ b/course/src/main/java/org/openedx/course/presentation/outline/CourseOutlineViewModel.kt @@ -27,6 +27,7 @@ import org.openedx.core.system.connection.NetworkConnection import org.openedx.core.system.notifier.CalendarSyncEvent.CreateCalendarSyncEvent import org.openedx.core.system.notifier.CourseNotifier import org.openedx.core.system.notifier.CourseStructureUpdated +import org.openedx.course.DatesShiftedSnackBar import org.openedx.course.domain.interactor.CourseInteractor import org.openedx.course.presentation.CourseAnalytics import org.openedx.course.presentation.calendarsync.CalendarSyncDialogType @@ -270,6 +271,7 @@ class CourseOutlineViewModel( try { interactor.resetCourseDates(courseId = courseId) updateCourseData(false) + _uiMessage.value = DatesShiftedSnackBar() onResetDates(true) } catch (e: Exception) { if (e.isInternetError()) { @@ -277,7 +279,7 @@ class CourseOutlineViewModel( UIMessage.SnackBarMessage(resourceManager.getString(R.string.core_error_no_connection)) } else { _uiMessage.value = - UIMessage.SnackBarMessage(resourceManager.getString(R.string.core_error_unknown_error)) + UIMessage.SnackBarMessage(resourceManager.getString(R.string.core_dates_shift_dates_unsuccessful_msg)) } onResetDates(false) } diff --git a/course/src/main/java/org/openedx/course/presentation/ui/CourseUI.kt b/course/src/main/java/org/openedx/course/presentation/ui/CourseUI.kt index 051466d25..e28b7b1cf 100644 --- a/course/src/main/java/org/openedx/course/presentation/ui/CourseUI.kt +++ b/course/src/main/java/org/openedx/course/presentation/ui/CourseUI.kt @@ -39,6 +39,7 @@ import androidx.compose.material.LinearProgressIndicator import androidx.compose.material.MaterialTheme import androidx.compose.material.OutlinedButton import androidx.compose.material.Scaffold +import androidx.compose.material.Snackbar import androidx.compose.material.Surface import androidx.compose.material.Text import androidx.compose.material.icons.Icons @@ -110,7 +111,7 @@ import org.openedx.course.presentation.outline.CourseOutlineFragment import subtitleFile.Caption import subtitleFile.TimedTextObject import java.util.Date -import org.openedx.course.R as courseR +import org.openedx.core.R as coreR @Composable fun CourseImageHeader( @@ -138,8 +139,8 @@ fun CourseImageHeader( AsyncImage( model = ImageRequest.Builder(LocalContext.current) .data(imageUrl) - .error(org.openedx.core.R.drawable.core_no_image_course) - .placeholder(org.openedx.core.R.drawable.core_no_image_course) + .error(coreR.drawable.core_no_image_course) + .placeholder(coreR.drawable.core_no_image_course) .build(), contentDescription = stringResource( id = R.string.course_accessibility_header_image_for, @@ -447,9 +448,9 @@ fun NavigationUnitsButtons( onNextClick: () -> Unit ) { val nextButtonIcon = if (hasNextBlock) { - painterResource(id = org.openedx.core.R.drawable.core_ic_down) + painterResource(id = coreR.drawable.core_ic_down) } else { - painterResource(id = org.openedx.core.R.drawable.core_ic_check_in_box) + painterResource(id = coreR.drawable.core_ic_check_in_box) } val subModifier = @@ -496,7 +497,7 @@ fun NavigationUnitsButtons( Spacer(Modifier.width(8.dp)) Icon( modifier = Modifier.rotate(if (isVerticalNavigation) 0f else -90f), - painter = painterResource(id = org.openedx.core.R.drawable.core_ic_up), + painter = painterResource(id = coreR.drawable.core_ic_up), contentDescription = null, tint = MaterialTheme.appColors.primary ) @@ -663,7 +664,7 @@ fun VideoSubtitles( horizontalArrangement = Arrangement.SpaceBetween ) { Text( - text = stringResource(id = courseR.string.course_subtitles), + text = stringResource(id = R.string.course_subtitles), color = MaterialTheme.appColors.textPrimary, style = MaterialTheme.appTypography.titleMedium ) @@ -673,7 +674,7 @@ fun VideoSubtitles( onSettingsClick() }, text = subtitleLanguage, - painter = painterResource(id = courseR.drawable.course_ic_cc), + painter = painterResource(id = R.drawable.course_ic_cc), color = MaterialTheme.appColors.textAccent, textStyle = MaterialTheme.appTypography.labelLarge ) @@ -1157,6 +1158,59 @@ fun CourseDatesBannerTablet( } } +@Composable +fun DatesShiftedSnackBar( + showAction: Boolean = false, + onViewDates: () -> Unit? = {}, + onClose: () -> Unit? = {}, +) { + Snackbar( + modifier = Modifier.padding(16.dp), + backgroundColor = MaterialTheme.appColors.background + ) { + Column(modifier = Modifier.padding(4.dp)) { + Box { + Text( + modifier = Modifier + .fillMaxWidth() + .align(Alignment.CenterStart), + text = stringResource(id = coreR.string.core_dates_shift_dates_successfully_title), + color = MaterialTheme.appColors.textFieldText, + style = MaterialTheme.appTypography.titleMedium + ) + IconButton(modifier = Modifier.align(Alignment.TopEnd), onClick = { onClose() }) { + Icon( + imageVector = Icons.Filled.Close, + contentDescription = "close", + tint = MaterialTheme.appColors.onBackground, + ) + } + } + Text( + modifier = Modifier + .padding(top = 4.dp) + .fillMaxWidth(), + text = stringResource(id = coreR.string.core_dates_shift_dates_successfully_msg), + color = MaterialTheme.appColors.textFieldText, + style = MaterialTheme.appTypography.titleSmall, + ) + if (showAction) { + OpenEdXOutlinedButton( + modifier = Modifier + .padding(top = 16.dp) + .fillMaxWidth(), + text = stringResource(id = coreR.string.core_dates_view_all_dates), + backgroundColor = MaterialTheme.appColors.background, + textColor = MaterialTheme.appColors.primary, + borderColor = MaterialTheme.appColors.primary, + onClick = { + onViewDates() + }) + } + } + } +} + @Preview(uiMode = Configuration.UI_MODE_NIGHT_NO) @Preview(uiMode = Configuration.UI_MODE_NIGHT_YES) @Composable diff --git a/course/src/main/res/values/strings.xml b/course/src/main/res/values/strings.xml index 63e1555de..d05d97808 100644 --- a/course/src/main/res/values/strings.xml +++ b/course/src/main/res/values/strings.xml @@ -51,6 +51,7 @@ You are already enrolled in this course. Discover You cannot change the download video quality when all videos are downloading + Dates Shifted Course dates are not currently available. From f294057b18e02df8cf08bb570ae249e0af41df5f Mon Sep 17 00:00:00 2001 From: PavloNetrebchuk <141041606+PavloNetrebchuk@users.noreply.github.com> Date: Wed, 27 Mar 2024 16:32:48 +0200 Subject: [PATCH 27/39] feat: Text and icons adjustment (#266) * feat: Profile UI changes * feat: Auth UI changes * feat: Navigation UI changes * feat: Outline UI changes * refactor: Created WarningLabel * fix: Strings fix ("Logout") * fix: Strings fix ("Logout") * fix: String and color changes, files auto-formatting --- .../restore/RestorePasswordFragment.kt | 4 +- .../presentation/signin/compose/SignInView.kt | 2 +- .../presentation/signup/compose/SignUpView.kt | 2 +- .../auth/presentation/ui/SocialAuthView.kt | 4 +- auth/src/main/res/drawable/auth_ic_email.xml | 40 +---- .../java/org/openedx/core/ui/ComposeCommon.kt | 79 +++++---- .../org/openedx/core/ui/theme/AppColors.kt | 1 + .../java/org/openedx/core/ui/theme/Theme.kt | 2 + .../src/main/res/drawable/core_ic_offline.xml | 44 +++++ core/src/main/res/drawable/core_ic_reload.xml | 23 +++ .../drawable/core_no_internet_connection.xml | 9 + core/src/main/res/values-uk/strings.xml | 1 - core/src/main/res/values/strings.xml | 3 +- .../org/openedx/core/ui/theme/Colors.kt | 2 + .../presentation/ChapterEndFragmentDialog.kt | 4 +- .../container/CourseContainerAdapter.kt | 2 +- .../detail/CourseDetailsFragment.kt | 162 +++++++++++------- .../outline/CourseOutlineFragment.kt | 2 +- .../course/presentation/ui/CourseUI.kt | 69 +++++++- .../course_ic_not_supported_block.xml | 39 ++--- .../res/menu/bottom_course_container_menu.xml | 2 +- course/src/main/res/values-uk/strings.xml | 4 +- course/src/main/res/values/strings.xml | 11 +- .../dashboard/DashboardFragment.kt | 10 -- dashboard/src/main/res/values-uk/strings.xml | 2 +- dashboard/src/main/res/values/strings.xml | 1 - .../threads/DiscussionAddThreadFragment.kt | 2 +- .../presentation/edit/EditProfileFragment.kt | 58 +++---- .../profile/compose/ProfileView.kt | 2 +- .../res/drawable-night/profile_ic_save.xml | 40 ----- .../src/main/res/drawable/profile_ic_exit.xml | 48 +----- .../src/main/res/drawable/profile_ic_save.xml | 43 +---- profile/src/main/res/values-uk/strings.xml | 2 - profile/src/main/res/values/strings.xml | 10 +- 34 files changed, 379 insertions(+), 350 deletions(-) create mode 100644 core/src/main/res/drawable/core_ic_offline.xml create mode 100644 core/src/main/res/drawable/core_ic_reload.xml create mode 100644 core/src/main/res/drawable/core_no_internet_connection.xml delete mode 100644 profile/src/main/res/drawable-night/profile_ic_save.xml 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 dd530bfa0..9b0740652 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 @@ -289,7 +289,7 @@ private fun RestorePasswordScreen( } } else { OpenEdXButton( - width = buttonWidth.testTag("btn_reset_password"), + modifier = buttonWidth.testTag("btn_reset_password"), text = stringResource(id = authR.string.auth_reset_password), onClick = { onRestoreButtonClick(email) @@ -337,7 +337,7 @@ private fun RestorePasswordScreen( ) Spacer(Modifier.height(48.dp)) OpenEdXButton( - width = buttonWidth, + modifier = buttonWidth, text = stringResource(id = R.string.core_sign_in), onClick = { onBackClick() diff --git a/auth/src/main/java/org/openedx/auth/presentation/signin/compose/SignInView.kt b/auth/src/main/java/org/openedx/auth/presentation/signin/compose/SignInView.kt index c40884d6f..77e290994 100644 --- a/auth/src/main/java/org/openedx/auth/presentation/signin/compose/SignInView.kt +++ b/auth/src/main/java/org/openedx/auth/presentation/signin/compose/SignInView.kt @@ -273,7 +273,7 @@ private fun AuthForm( CircularProgressIndicator(color = MaterialTheme.appColors.primary) } else { OpenEdXButton( - width = buttonWidth.testTag("btn_sign_in"), + modifier = buttonWidth.testTag("btn_sign_in"), text = stringResource(id = coreR.string.core_sign_in), onClick = { onEvent(AuthEvent.SignIn(login = login, password = password)) diff --git a/auth/src/main/java/org/openedx/auth/presentation/signup/compose/SignUpView.kt b/auth/src/main/java/org/openedx/auth/presentation/signup/compose/SignUpView.kt index 2852ab5fe..2e2180d83 100644 --- a/auth/src/main/java/org/openedx/auth/presentation/signup/compose/SignUpView.kt +++ b/auth/src/main/java/org/openedx/auth/presentation/signup/compose/SignUpView.kt @@ -435,7 +435,7 @@ internal fun SignUpView( } } else { OpenEdXButton( - width = buttonWidth.testTag("btn_create_account"), + modifier = buttonWidth.testTag("btn_create_account"), text = stringResource(id = R.string.auth_create_account), onClick = { showErrorMap.clear() diff --git a/auth/src/main/java/org/openedx/auth/presentation/ui/SocialAuthView.kt b/auth/src/main/java/org/openedx/auth/presentation/ui/SocialAuthView.kt index c9d73662b..336c09f8f 100644 --- a/auth/src/main/java/org/openedx/auth/presentation/ui/SocialAuthView.kt +++ b/auth/src/main/java/org/openedx/auth/presentation/ui/SocialAuthView.kt @@ -74,7 +74,7 @@ internal fun SocialAuthView( R.string.auth_continue_facebook } OpenEdXButton( - width = Modifier + modifier = Modifier .testTag("btn_facebook_auth") .padding(top = 12.dp) .fillMaxWidth(), @@ -106,7 +106,7 @@ internal fun SocialAuthView( R.string.auth_continue_microsoft } OpenEdXButton( - width = Modifier + modifier = Modifier .testTag("btn_microsoft_auth") .padding(top = 12.dp) .fillMaxWidth(), diff --git a/auth/src/main/res/drawable/auth_ic_email.xml b/auth/src/main/res/drawable/auth_ic_email.xml index c76bdb4c0..3fdb37de2 100644 --- a/auth/src/main/res/drawable/auth_ic_email.xml +++ b/auth/src/main/res/drawable/auth_ic_email.xml @@ -1,34 +1,10 @@ - - - - - - - - - + android:width="85dp" + android:height="66dp" + android:viewportWidth="85" + android:viewportHeight="66"> + 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 7114426c1..17ac1524b 100644 --- a/core/src/main/java/org/openedx/core/ui/ComposeCommon.kt +++ b/core/src/main/java/org/openedx/core/ui/ComposeCommon.kt @@ -40,7 +40,6 @@ 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.material.icons.filled.Wifi import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.NonRestartableComposable @@ -1046,32 +1045,40 @@ fun OfflineModeDialog( Row( Modifier .fillMaxWidth() - .padding(16.dp), + .padding(vertical = 10.dp, horizontal = 24.dp), horizontalArrangement = Arrangement.SpaceBetween ) { - Text( - modifier = Modifier.testTag("txt_offline_label"), + IconText( text = stringResource(id = R.string.core_offline), - style = MaterialTheme.appTypography.labelMedium, - color = MaterialTheme.appColors.textDark + painter = painterResource(id = R.drawable.core_ic_offline), + color = Color.Black, + textStyle = MaterialTheme.appTypography.titleSmall ) - Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { - Text( - modifier = Modifier - .testTag("txt_dismiss") - .clickable { onDismissCLick() }, - text = stringResource(id = R.string.core_dismiss), - style = MaterialTheme.appTypography.labelMedium, - color = MaterialTheme.appColors.primary - ) - Text( - modifier = Modifier - .testTag("txt_reload") - .clickable { onReloadClick() }, - text = stringResource(id = R.string.core_reload), - style = MaterialTheme.appTypography.labelMedium, - color = MaterialTheme.appColors.primary - ) + Row(horizontalArrangement = Arrangement.spacedBy(36.dp)) { + IconButton( + modifier = Modifier.size(20.dp), + onClick = { + onReloadClick() + }) { + Icon( + modifier = Modifier.size(20.dp), + painter = painterResource(R.drawable.core_ic_reload), + contentDescription = null, + tint = MaterialTheme.appColors.primary + ) + } + IconButton( + modifier = Modifier.size(20.dp), + onClick = { + onDismissCLick() + }) { + Icon( + modifier = Modifier.size(20.dp), + imageVector = Icons.Filled.Close, + contentDescription = null, + tint = Color.Black + ) + } } } } @@ -1079,7 +1086,7 @@ fun OfflineModeDialog( @Composable fun OpenEdXButton( - width: Modifier = Modifier.fillMaxWidth(), + modifier: Modifier = Modifier.fillMaxWidth(), text: String = "", onClick: () -> Unit, enabled: Boolean = true, @@ -1089,7 +1096,7 @@ fun OpenEdXButton( Button( modifier = Modifier .testTag("btn_${text.tagId()}") - .then(width) + .then(modifier) .height(42.dp), shape = MaterialTheme.appShapes.buttonShape, colors = ButtonDefaults.buttonColors( @@ -1172,23 +1179,29 @@ fun ConnectionErrorView( ) { Icon( modifier = Modifier.size(100.dp), - imageVector = Icons.Filled.Wifi, + painter = painterResource(id = R.drawable.core_no_internet_connection), contentDescription = null, tint = MaterialTheme.appColors.onSurface ) + Spacer(Modifier.height(28.dp)) + Text( + modifier = Modifier.fillMaxWidth(0.8f), + text = stringResource(id = R.string.core_no_internet_connection), + color = MaterialTheme.appColors.textPrimary, + style = MaterialTheme.appTypography.titleLarge, + textAlign = TextAlign.Center + ) Spacer(Modifier.height(16.dp)) Text( - modifier = Modifier - .testTag("txt_connection_error_label") - .fillMaxWidth(0.6f), - text = stringResource(id = R.string.core_not_connected_to_internet), + modifier = Modifier.fillMaxWidth(0.8f), + text = stringResource(id = R.string.core_no_internet_connection_description), color = MaterialTheme.appColors.textPrimary, - style = MaterialTheme.appTypography.titleMedium, + style = MaterialTheme.appTypography.bodyLarge, textAlign = TextAlign.Center ) Spacer(Modifier.height(16.dp)) OpenEdXButton( - width = Modifier + modifier = Modifier .widthIn(Dp.Unspecified, 162.dp), text = stringResource(id = R.string.core_reload), onClick = onReloadClick @@ -1203,7 +1216,7 @@ fun AuthButtonsPanel( ) { Row { OpenEdXButton( - width = Modifier + modifier = Modifier .testTag("btn_register") .width(0.dp) .weight(1f), diff --git a/core/src/main/java/org/openedx/core/ui/theme/AppColors.kt b/core/src/main/java/org/openedx/core/ui/theme/AppColors.kt index 3a540d9fa..9db8faa60 100644 --- a/core/src/main/java/org/openedx/core/ui/theme/AppColors.kt +++ b/core/src/main/java/org/openedx/core/ui/theme/AppColors.kt @@ -11,6 +11,7 @@ data class AppColors( val textSecondary: Color, val textDark: Color, val textAccent: Color, + val textWarning: Color, val textFieldBackground: Color, val textFieldBackgroundVariant: Color, 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 e5e7a00d3..01ae95110 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 @@ -30,6 +30,7 @@ private val DarkColorPalette = AppColors( textSecondary = dark_text_secondary, textDark = dark_text_dark, textAccent = dark_text_accent, + textWarning = dark_text_warning, textFieldBackground = dark_text_field_background, textFieldBackgroundVariant = dark_text_field_background_variant, @@ -92,6 +93,7 @@ private val LightColorPalette = AppColors( textSecondary = light_text_secondary, textDark = light_text_dark, textAccent = light_text_accent, + textWarning = light_text_warning, textFieldBackground = light_text_field_background, textFieldBackgroundVariant = light_text_field_background_variant, diff --git a/core/src/main/res/drawable/core_ic_offline.xml b/core/src/main/res/drawable/core_ic_offline.xml new file mode 100644 index 000000000..020f42218 --- /dev/null +++ b/core/src/main/res/drawable/core_ic_offline.xml @@ -0,0 +1,44 @@ + + + + + + + + + + diff --git a/core/src/main/res/drawable/core_ic_reload.xml b/core/src/main/res/drawable/core_ic_reload.xml new file mode 100644 index 000000000..d2f423f05 --- /dev/null +++ b/core/src/main/res/drawable/core_ic_reload.xml @@ -0,0 +1,23 @@ + + + + + + + diff --git a/core/src/main/res/drawable/core_no_internet_connection.xml b/core/src/main/res/drawable/core_no_internet_connection.xml new file mode 100644 index 000000000..11d9f58f0 --- /dev/null +++ b/core/src/main/res/drawable/core_no_internet_connection.xml @@ -0,0 +1,9 @@ + + + diff --git a/core/src/main/res/values-uk/strings.xml b/core/src/main/res/values-uk/strings.xml index 5287ad2ea..b012036fb 100644 --- a/core/src/main/res/values-uk/strings.xml +++ b/core/src/main/res/values-uk/strings.xml @@ -58,7 +58,6 @@ Оцінити нас Дякуємо за надання відгуку. Чи бажаєте ви поділитися своєю оцінкою цього додатка з іншими користувачами в магазині додатків? Ми отримали ваш відгук і використовуватимемо його, щоб покращити ваш досвід навчання в майбутньому. Дякуємо, що поділилися! - Ви не підключені до Інтернету. Будь ласка, перевірте ваше підключення до Інтернету. Зареєструватися Увійти diff --git a/core/src/main/res/values/strings.xml b/core/src/main/res/values/strings.xml index fdeefc411..c2c9927c3 100644 --- a/core/src/main/res/values/strings.xml +++ b/core/src/main/res/values/strings.xml @@ -73,7 +73,8 @@ 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. + No internet connection + Please connect to the internet to view this content. OK Continue Leaving the app diff --git a/core/src/openedx/org/openedx/core/ui/theme/Colors.kt b/core/src/openedx/org/openedx/core/ui/theme/Colors.kt index 29f09b21b..35d695cc1 100644 --- a/core/src/openedx/org/openedx/core/ui/theme/Colors.kt +++ b/core/src/openedx/org/openedx/core/ui/theme/Colors.kt @@ -19,6 +19,7 @@ val light_text_primary_variant = Color(0xFF3D4964) val light_text_secondary = Color(0xFFB3B3B3) val light_text_dark = Color(0xFF19212F) val light_text_accent = Color(0xFF3C68FF) +val light_text_warning= Color(0xFF19212F) val light_text_field_background = Color(0xFFF7F7F8) val light_text_field_background_variant = Color.White val light_text_field_border = Color(0xFF97A5BB) @@ -68,6 +69,7 @@ val dark_text_primary_variant = Color(0xFF79889F) val dark_text_secondary = Color(0xFFB3B3B3) val dark_text_dark = Color.White val dark_text_accent = Color(0xFF879FF5) +val dark_text_warning= Color(0xFF19212F) val dark_text_field_background = Color(0xFF273346) val dark_text_field_background_variant = Color(0xFF273346) val dark_text_field_border = Color(0xFF4E5A70) diff --git a/course/src/main/java/org/openedx/course/presentation/ChapterEndFragmentDialog.kt b/course/src/main/java/org/openedx/course/presentation/ChapterEndFragmentDialog.kt index cdedd3424..e84766780 100644 --- a/course/src/main/java/org/openedx/course/presentation/ChapterEndFragmentDialog.kt +++ b/course/src/main/java/org/openedx/course/presentation/ChapterEndFragmentDialog.kt @@ -189,7 +189,7 @@ private fun ChapterEndDialogScreen( ) Spacer(Modifier.height(36.dp)) Text( - text = stringResource(id = R.string.course_good_work), + text = stringResource(id = R.string.course_good_job), color = MaterialTheme.appColors.textPrimary, style = MaterialTheme.appTypography.titleLarge ) @@ -300,7 +300,7 @@ private fun ChapterEndDialogScreenLandscape( Spacer(Modifier.height(36.dp)) Text( modifier = Modifier.fillMaxWidth(), - text = stringResource(id = R.string.course_good_work), + text = stringResource(id = R.string.course_good_job), color = MaterialTheme.appColors.textPrimary, style = MaterialTheme.appTypography.titleLarge, textAlign = TextAlign.Center diff --git a/course/src/main/java/org/openedx/course/presentation/container/CourseContainerAdapter.kt b/course/src/main/java/org/openedx/course/presentation/container/CourseContainerAdapter.kt index d9447487c..defa6b8a7 100644 --- a/course/src/main/java/org/openedx/course/presentation/container/CourseContainerAdapter.kt +++ b/course/src/main/java/org/openedx/course/presentation/container/CourseContainerAdapter.kt @@ -27,5 +27,5 @@ enum class CourseContainerTab(val itemId: Int, val titleResId: Int) { VIDEOS(itemId = R.id.videos, titleResId = R.string.course_navigation_videos), DISCUSSION(itemId = R.id.discussions, titleResId = R.string.course_navigation_discussions), DATES(itemId = R.id.dates, titleResId = R.string.course_navigation_dates), - HANDOUTS(itemId = R.id.resources, titleResId = R.string.course_navigation_handouts), + HANDOUTS(itemId = R.id.resources, titleResId = R.string.course_navigation_more), } diff --git a/course/src/main/java/org/openedx/course/presentation/detail/CourseDetailsFragment.kt b/course/src/main/java/org/openedx/course/presentation/detail/CourseDetailsFragment.kt index 8b031fc4f..5e3b852ce 100644 --- a/course/src/main/java/org/openedx/course/presentation/detail/CourseDetailsFragment.kt +++ b/course/src/main/java/org/openedx/course/presentation/detail/CourseDetailsFragment.kt @@ -2,7 +2,10 @@ package org.openedx.course.presentation.detail import android.annotation.SuppressLint import android.content.Intent -import android.content.res.Configuration.* +import android.content.res.Configuration.ORIENTATION_LANDSCAPE +import android.content.res.Configuration.ORIENTATION_PORTRAIT +import android.content.res.Configuration.UI_MODE_NIGHT_NO +import android.content.res.Configuration.UI_MODE_NIGHT_YES import android.net.Uri import android.os.Bundle import android.view.LayoutInflater @@ -10,21 +13,56 @@ import android.view.ViewGroup import android.webkit.WebResourceRequest import android.webkit.WebView import android.webkit.WebViewClient -import androidx.compose.foundation.* -import androidx.compose.foundation.layout.* -import androidx.compose.material.* +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.Spacer +import androidx.compose.foundation.layout.aspectRatio +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.heightIn +import androidx.compose.foundation.layout.navigationBarsPadding +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.CircularProgressIndicator +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.outlined.Report -import androidx.compose.runtime.* +import androidx.compose.material.rememberScaffoldState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.MutableState +import androidx.compose.runtime.getValue import androidx.compose.runtime.livedata.observeAsState +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.ExperimentalComposeUiApi import androidx.compose.ui.Modifier import androidx.compose.ui.draw.alpha -import androidx.compose.ui.draw.shadow import androidx.compose.ui.graphics.Color -import androidx.compose.ui.platform.* +import androidx.compose.ui.graphics.vector.rememberVectorPainter +import androidx.compose.ui.platform.ComposeView +import androidx.compose.ui.platform.LocalConfiguration +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalUriHandler +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 @@ -44,17 +82,28 @@ import org.openedx.core.UIMessage import org.openedx.core.domain.model.Course import org.openedx.core.domain.model.Media import org.openedx.core.extension.isEmailValid -import org.openedx.core.ui.* +import org.openedx.core.ui.AuthButtonsPanel +import org.openedx.core.ui.HandleUIMessage +import org.openedx.core.ui.OfflineModeDialog +import org.openedx.core.ui.OpenEdXButton +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.isPreview +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.openedx.core.ui.windowSizeValue import org.openedx.core.utils.EmailUtil import org.openedx.course.R import org.openedx.course.presentation.CourseRouter import org.openedx.course.presentation.ui.CourseImageHeader +import org.openedx.course.presentation.ui.WarningLabel import java.nio.charset.StandardCharsets -import java.util.* +import java.util.Date import org.openedx.course.R as courseR class CourseDetailsFragment : Fragment() { @@ -169,7 +218,7 @@ internal fun CourseDetailsScreen( val scaffoldState = rememberScaffoldState() val configuration = LocalConfiguration.current - var isInternetConnectionShown by rememberSaveable { + val isInternetConnectionShown = rememberSaveable { mutableStateOf(false) } @@ -262,6 +311,8 @@ internal fun CourseDetailsScreen( CourseDetailNativeContentLandscape( windowSize = windowSize, apiHostUrl = apiHostUrl, + hasInternetConnection = hasInternetConnection, + isInternetConnectionShown = isInternetConnectionShown, course = uiState.course, onButtonClick = { onButtonClick() @@ -271,6 +322,8 @@ internal fun CourseDetailsScreen( CourseDetailNativeContent( windowSize = windowSize, apiHostUrl = apiHostUrl, + hasInternetConnection = hasInternetConnection, + isInternetConnectionShown = isInternetConnectionShown, course = uiState.course, onButtonClick = { onButtonClick() @@ -285,7 +338,7 @@ internal fun CourseDetailsScreen( .padding(all = 20.dp), ) } else { - var webViewAlpha by remember { mutableStateOf(0f) } + var webViewAlpha by remember { mutableFloatStateOf(0f) } if (webViewAlpha == 0f) { Box( modifier = Modifier @@ -315,16 +368,16 @@ internal fun CourseDetailsScreen( } } } - if (!isInternetConnectionShown && !hasInternetConnection) { + if (!isInternetConnectionShown.value && !hasInternetConnection) { OfflineModeDialog( Modifier .fillMaxWidth() .align(Alignment.BottomCenter), onDismissCLick = { - isInternetConnectionShown = true + isInternetConnectionShown.value = true }, onReloadClick = { - isInternetConnectionShown = true + isInternetConnectionShown.value = true onReloadClick() } ) @@ -341,6 +394,8 @@ private fun CourseDetailNativeContent( windowSize: WindowSize, apiHostUrl: String, course: Course, + hasInternetConnection: Boolean, + isInternetConnectionShown: MutableState, onButtonClick: () -> Unit, ) { val uriHandler = LocalUriHandler.current @@ -402,7 +457,11 @@ private fun CourseDetailNativeContent( .padding(horizontal = contentHorizontalPadding) ) { val enrollmentEnd = course.enrollmentEnd - if (enrollmentEnd != null && Date() > enrollmentEnd) { + if (!hasInternetConnection) { + isInternetConnectionShown.value = true + NoInternetLabel() + Spacer(Modifier.height(24.dp)) + } else if (enrollmentEnd != null && Date() > enrollmentEnd) { EnrollOverLabel() Spacer(Modifier.height(24.dp)) } @@ -429,7 +488,7 @@ private fun CourseDetailNativeContent( if (!(enrollmentEnd != null && Date() > enrollmentEnd)) { Spacer(Modifier.height(32.dp)) OpenEdXButton( - width = buttonWidth, + modifier = buttonWidth, text = buttonText, onClick = onButtonClick ) @@ -444,6 +503,8 @@ private fun CourseDetailNativeContentLandscape( windowSize: WindowSize, apiHostUrl: String, course: Course, + hasInternetConnection: Boolean, + isInternetConnectionShown: MutableState, onButtonClick: () -> Unit, ) { val uriHandler = LocalUriHandler.current @@ -496,12 +557,16 @@ private fun CourseDetailNativeContentLandscape( Spacer(Modifier.height(42.dp)) } val enrollmentEnd = course.enrollmentEnd - if (enrollmentEnd != null && Date() > enrollmentEnd) { - Spacer(Modifier.height(4.dp)) + if (!hasInternetConnection) { + isInternetConnectionShown.value = true + NoInternetLabel() + Spacer(Modifier.height(24.dp)) + } else if (enrollmentEnd != null && Date() > enrollmentEnd) { EnrollOverLabel() + Spacer(Modifier.height(24.dp)) } else { OpenEdXButton( - width = buttonWidth, + modifier = buttonWidth, text = buttonText, onClick = onButtonClick ) @@ -539,51 +604,18 @@ private fun CourseDetailNativeContentLandscape( @Composable private fun EnrollOverLabel() { - val borderColor = if (!isSystemInDarkTheme()) { - MaterialTheme.appColors.cardViewBorder - } else { - MaterialTheme.appColors.surface - } - Box( - Modifier - .fillMaxWidth() - .shadow( - 0.dp, - MaterialTheme.appShapes.material.medium - ) - .background( - MaterialTheme.appColors.surface, - MaterialTheme.appShapes.material.medium - ) - .border( - 1.dp, - borderColor, - MaterialTheme.appShapes.material.medium - ) - ) { - Row( - modifier = Modifier - .fillMaxWidth() - .padding( - horizontal = 16.dp, - vertical = 12.dp - ), - verticalAlignment = Alignment.CenterVertically - ) { - Icon( - imageVector = Icons.Outlined.Report, - contentDescription = null, - tint = MaterialTheme.appColors.warning - ) - Spacer(Modifier.width(12.dp)) - Text( - modifier = Modifier.testTag("txt_enroll_error"), - text = stringResource(id = courseR.string.course_you_cant_enroll), - color = MaterialTheme.appColors.textPrimaryVariant, - style = MaterialTheme.appTypography.titleSmall - ) - } - } + WarningLabel( + painter = rememberVectorPainter(Icons.Outlined.Report), + text = stringResource(id = courseR.string.course_you_cant_enroll) + ) +} + +@Composable +private fun NoInternetLabel() { + WarningLabel( + painter = painterResource(id = org.openedx.core.R.drawable.core_ic_offline), + text = stringResource(id = courseR.string.course_no_internet_label) + ) } @Composable diff --git a/course/src/main/java/org/openedx/course/presentation/outline/CourseOutlineFragment.kt b/course/src/main/java/org/openedx/course/presentation/outline/CourseOutlineFragment.kt index c55d622cf..4d9dfc54b 100644 --- a/course/src/main/java/org/openedx/course/presentation/outline/CourseOutlineFragment.kt +++ b/course/src/main/java/org/openedx/course/presentation/outline/CourseOutlineFragment.kt @@ -613,7 +613,7 @@ private fun ResumeCourseTablet( } } OpenEdXButton( - width = Modifier.width(210.dp), + modifier = Modifier.width(210.dp), text = stringResource(id = org.openedx.course.R.string.course_resume), onClick = { onResumeClick(block.id) diff --git a/course/src/main/java/org/openedx/course/presentation/ui/CourseUI.kt b/course/src/main/java/org/openedx/course/presentation/ui/CourseUI.kt index e28b7b1cf..23c31e843 100644 --- a/course/src/main/java/org/openedx/course/presentation/ui/CourseUI.kt +++ b/course/src/main/java/org/openedx/course/presentation/ui/CourseUI.kt @@ -9,6 +9,7 @@ import androidx.compose.foundation.Image import androidx.compose.foundation.background import androidx.compose.foundation.border import androidx.compose.foundation.clickable +import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -59,7 +60,9 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.draw.alpha import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.rotate +import androidx.compose.ui.draw.shadow import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.painter.Painter import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalConfiguration import androidx.compose.ui.platform.LocalContext @@ -1150,7 +1153,7 @@ fun CourseDatesBannerTablet( } banner.bannerType.buttonResId.nonZero()?.let { OpenEdXButton( - width = Modifier.width(210.dp), + modifier = Modifier.width(210.dp), text = stringResource(id = it), onClick = resetDates, ) @@ -1211,6 +1214,70 @@ fun DatesShiftedSnackBar( } } +@Composable +fun WarningLabel( + painter: Painter, + text: String +) { + val borderColor = if (!isSystemInDarkTheme()) { + MaterialTheme.appColors.cardViewBorder + } else { + MaterialTheme.appColors.surface + } + Box( + Modifier + .fillMaxWidth() + .shadow( + 0.dp, + MaterialTheme.appShapes.material.medium + ) + .background( + MaterialTheme.appColors.surface, + MaterialTheme.appShapes.material.medium + ) + .border( + 1.dp, + borderColor, + MaterialTheme.appShapes.material.medium + ) + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding( + horizontal = 16.dp, + vertical = 12.dp + ), + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + painter = painter, + contentDescription = null, + tint = MaterialTheme.appColors.warning + ) + Spacer(Modifier.width(12.dp)) + Text( + modifier = Modifier.testTag("txt_enroll_internet_error"), + text = text, + color = MaterialTheme.appColors.textPrimaryVariant, + style = MaterialTheme.appTypography.titleSmall + ) + } + } +} + +@Preview(uiMode = Configuration.UI_MODE_NIGHT_NO) +@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES) +@Composable +private fun WarningLabelPreview() { + OpenEdXTheme { + WarningLabel( + painter = painterResource(id = org.openedx.core.R.drawable.core_ic_offline), + text = stringResource(id = R.string.course_no_internet_label) + ) + } +} + @Preview(uiMode = Configuration.UI_MODE_NIGHT_NO) @Preview(uiMode = Configuration.UI_MODE_NIGHT_YES) @Composable diff --git a/course/src/main/res/drawable/course_ic_not_supported_block.xml b/course/src/main/res/drawable/course_ic_not_supported_block.xml index 474a658b0..470876362 100644 --- a/course/src/main/res/drawable/course_ic_not_supported_block.xml +++ b/course/src/main/res/drawable/course_ic_not_supported_block.xml @@ -3,31 +3,16 @@ android:height="100dp" android:viewportWidth="100" android:viewportHeight="100"> - - - - - - - - - + + + + diff --git a/course/src/main/res/menu/bottom_course_container_menu.xml b/course/src/main/res/menu/bottom_course_container_menu.xml index 97529a580..a8a77e606 100644 --- a/course/src/main/res/menu/bottom_course_container_menu.xml +++ b/course/src/main/res/menu/bottom_course_container_menu.xml @@ -27,7 +27,7 @@ diff --git a/course/src/main/res/values-uk/strings.xml b/course/src/main/res/values-uk/strings.xml index 28f5a4628..7ac53f4da 100644 --- a/course/src/main/res/values-uk/strings.xml +++ b/course/src/main/res/values-uk/strings.xml @@ -27,7 +27,7 @@ Оголошення Знайдіть важливу інформацію про курс Будьте в курсі останніх новин - Гарна робота! + Гарна робота! Секція \"%s\" завершена. Наступний розділ Повернутись до модуля @@ -37,7 +37,7 @@ Курс Відео Обговорення - Матеріали + Матеріали Ви можете завантажувати контент тільки через Wi-Fi Ця інтерактивна компонента ще не доступна Досліджуйте інші частини цього курсу або перегляньте це на веб-сайті. diff --git a/course/src/main/res/values/strings.xml b/course/src/main/res/values/strings.xml index d05d97808..4a4ef80ed 100644 --- a/course/src/main/res/values/strings.xml +++ b/course/src/main/res/values/strings.xml @@ -9,7 +9,7 @@ Course subsections Videos You have passed the course! Now you can get the certificate - You’ve passed the course + You’ve completed the course Congratulations! View the certificate You can get a certificate after completing the course (earn required grade) @@ -27,19 +27,20 @@ Announcements Find important course information Keep up with the latest news - Good work! - Section \"%s\" is finished. + Good job! + You\'ve completed \"%s\". Back to outline Next section You cannot enroll in this course because the enrollment date is over. + To enroll in this course, please make sure you are connected to the internet. This course hasn’t started yet. You are not connected to the Internet. Please check your Internet connection. Course Videos Discussions - Handouts + More You can download content only from Wi-fi - This interactive component isn’t yet available + This interactive component isn\'t available on mobile. Explore other parts of this course or view this on web. Open in browser Subtitles diff --git a/dashboard/src/main/java/org/openedx/dashboard/presentation/dashboard/DashboardFragment.kt b/dashboard/src/main/java/org/openedx/dashboard/presentation/dashboard/DashboardFragment.kt index 0582f663e..85cd83d5d 100644 --- a/dashboard/src/main/java/org/openedx/dashboard/presentation/dashboard/DashboardFragment.kt +++ b/dashboard/src/main/java/org/openedx/dashboard/presentation/dashboard/DashboardFragment.kt @@ -513,16 +513,6 @@ private fun EmptyState() { tint = MaterialTheme.appColors.textFieldBorder ) Spacer(Modifier.height(16.dp)) - Text( - modifier = Modifier - .testTag("txt_empty_state_title") - .fillMaxWidth(), - text = stringResource(id = R.string.dashboard_its_empty), - color = MaterialTheme.appColors.textPrimary, - style = MaterialTheme.appTypography.titleMedium, - textAlign = TextAlign.Center - ) - Spacer(Modifier.height(8.dp)) Text( modifier = Modifier .testTag("txt_empty_state_description") diff --git a/dashboard/src/main/res/values-uk/strings.xml b/dashboard/src/main/res/values-uk/strings.xml index 4217c5d50..a7b3ef9d3 100644 --- a/dashboard/src/main/res/values-uk/strings.xml +++ b/dashboard/src/main/res/values-uk/strings.xml @@ -3,6 +3,6 @@ Мої курси Курси Ласкаво просимо назад. Продовжуймо навчатися. - It\'s empty You are not enrolled in any courses yet. + \ No newline at end of file diff --git a/dashboard/src/main/res/values/strings.xml b/dashboard/src/main/res/values/strings.xml index 5eb61aadc..4ba7b2f24 100644 --- a/dashboard/src/main/res/values/strings.xml +++ b/dashboard/src/main/res/values/strings.xml @@ -4,7 +4,6 @@ Courses Programs Welcome back. Let\'s keep learning. - It\'s empty You are not enrolled in any courses yet. You have been successfully enrolled in this course. 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 dda5a41f4..7604b1927 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 @@ -385,7 +385,7 @@ private fun DiscussionAddThreadScreen( CircularProgressIndicator(color = MaterialTheme.appColors.primary) } else { OpenEdXButton( - width = buttonWidth, + modifier = buttonWidth, text = stringResource(id = discussionR.string.discussion_create_post), onClick = { onPostDiscussionClick( 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 1efdc094e..c09294c5b 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 @@ -763,6 +763,7 @@ private fun LimitedProfileDialog( modifier: Modifier, onCloseClick: () -> Unit ) { + val tint = MaterialTheme.appColors.textWarning Column( modifier .shadow( @@ -774,43 +775,33 @@ private fun LimitedProfileDialog( MaterialTheme.appShapes.material.medium ) ) { - Column( - Modifier + Row( + modifier = Modifier .fillMaxWidth() - .padding(16.dp) + .padding(16.dp), + verticalAlignment = Alignment.Top, + horizontalArrangement = Arrangement.spacedBy(8.dp) ) { - Row(verticalAlignment = Alignment.CenterVertically) { - Icon( - imageVector = Icons.Outlined.Report, - contentDescription = null, - tint = MaterialTheme.appColors.textDark - ) - Spacer(modifier = Modifier.width(8.dp)) - Text( - modifier = Modifier - .testTag("txt_edit_profile_limited_profile_title") - .weight(1f), - text = stringResource(id = R.string.profile_oh_sorry), - color = MaterialTheme.appColors.textDark, - style = MaterialTheme.appTypography.titleMedium - ) - Icon( - modifier = Modifier - .testTag("ic_edit_profile_limited_profile_close") - .clickable { onCloseClick() }, - imageVector = Icons.Filled.Close, - contentDescription = null, - tint = MaterialTheme.appColors.textDark - ) - } - Spacer(modifier = Modifier.height(8.dp)) + Icon( + imageVector = Icons.Outlined.Report, + contentDescription = null, + tint = tint + ) Text( modifier = Modifier .testTag("txt_edit_profile_limited_profile_message") - .fillMaxWidth(), + .weight(1f), text = stringResource(id = R.string.profile_must_be_over), - color = MaterialTheme.appColors.textDark, - style = MaterialTheme.appTypography.bodyMedium + color = tint, + style = MaterialTheme.appTypography.labelLarge + ) + Icon( + modifier = Modifier + .testTag("ic_edit_profile_limited_profile_close") + .clickable { onCloseClick() }, + imageVector = Icons.Filled.Close, + contentDescription = null, + tint = tint ) } } @@ -1112,8 +1103,9 @@ private fun LeaveProfile( ) { Icon( modifier = Modifier - .size(100.dp), + .size(60.dp), painter = painterResource(R.drawable.profile_ic_save), + tint = MaterialTheme.appColors.textPrimary, contentDescription = null ) Spacer(Modifier.size(48.dp)) @@ -1145,7 +1137,7 @@ private fun LeaveProfile( .testTag("txt_leave") .fillMaxWidth(), text = stringResource(id = R.string.profile_leave), - color = MaterialTheme.appColors.textDark, + color = MaterialTheme.appColors.textWarning, style = MaterialTheme.appTypography.labelLarge, textAlign = TextAlign.Center ) 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 index 9ae81cc05..d8af99f72 100644 --- 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 @@ -484,7 +484,7 @@ private fun LogoutDialog( .testTag("txt_logout") .fillMaxWidth(), text = stringResource(id = org.openedx.profile.R.string.profile_logout), - color = MaterialTheme.appColors.textDark, + color = MaterialTheme.appColors.textWarning, style = MaterialTheme.appTypography.labelLarge, textAlign = TextAlign.Center ) diff --git a/profile/src/main/res/drawable-night/profile_ic_save.xml b/profile/src/main/res/drawable-night/profile_ic_save.xml deleted file mode 100644 index 71c48539c..000000000 --- a/profile/src/main/res/drawable-night/profile_ic_save.xml +++ /dev/null @@ -1,40 +0,0 @@ - - - - - - - - - - - - - - - diff --git a/profile/src/main/res/drawable/profile_ic_exit.xml b/profile/src/main/res/drawable/profile_ic_exit.xml index 7278b1b70..515e3091f 100644 --- a/profile/src/main/res/drawable/profile_ic_exit.xml +++ b/profile/src/main/res/drawable/profile_ic_exit.xml @@ -1,42 +1,10 @@ - - - - - - - - - - - - + android:width="63dp" + android:height="70dp" + android:viewportWidth="63" + android:viewportHeight="70"> + diff --git a/profile/src/main/res/drawable/profile_ic_save.xml b/profile/src/main/res/drawable/profile_ic_save.xml index 068e2956f..9132d406c 100644 --- a/profile/src/main/res/drawable/profile_ic_save.xml +++ b/profile/src/main/res/drawable/profile_ic_save.xml @@ -1,40 +1,9 @@ + android:width="62dp" + android:height="61dp" + android:viewportWidth="62" + android:viewportHeight="61"> - - - - - - - - - - - - + android:pathData="M60.05,47.443L38.893,5.162C38.113,3.61 36.916,2.306 35.437,1.394C33.959,0.483 32.256,0 30.518,0C28.781,0 27.078,0.483 25.599,1.394C24.121,2.306 22.924,3.61 22.143,5.162L0.987,47.443C0.273,48.874 -0.064,50.464 0.01,52.061C0.083,53.658 0.564,55.21 1.407,56.57C2.249,57.929 3.425,59.05 4.823,59.826C6.221,60.603 7.794,61.009 9.393,61.006H51.643C53.243,61.009 54.816,60.603 56.214,59.826C57.612,59.05 58.788,57.929 59.63,56.57C60.473,55.21 60.954,53.658 61.027,52.061C61.1,50.464 60.764,48.874 60.05,47.443ZM32.737,47.599C32.138,48.171 31.346,48.494 30.518,48.506C30.11,48.501 29.707,48.416 29.331,48.256C28.947,48.107 28.597,47.884 28.3,47.599C28.007,47.309 27.774,46.963 27.616,46.583C27.457,46.202 27.375,45.793 27.375,45.381C27.375,44.968 27.457,44.56 27.616,44.179C27.774,43.798 28.007,43.452 28.3,43.162C28.597,42.877 28.947,42.654 29.331,42.506C29.9,42.266 30.527,42.201 31.133,42.318C31.74,42.435 32.298,42.728 32.737,43.162C33.03,43.452 33.263,43.798 33.421,44.179C33.58,44.56 33.662,44.968 33.662,45.381C33.662,45.793 33.58,46.202 33.421,46.583C33.263,46.963 33.03,47.309 32.737,47.599ZM33.643,36.006C33.643,36.834 33.314,37.629 32.728,38.215C32.142,38.801 31.347,39.131 30.518,39.131C29.69,39.131 28.895,38.801 28.309,38.215C27.723,37.629 27.393,36.834 27.393,36.006V20.381C27.393,19.552 27.723,18.757 28.309,18.171C28.895,17.585 29.69,17.256 30.518,17.256C31.347,17.256 32.142,17.585 32.728,18.171C33.314,18.757 33.643,19.552 33.643,20.381V36.006Z" + android:fillColor="#ffffff"/> diff --git a/profile/src/main/res/values-uk/strings.xml b/profile/src/main/res/values-uk/strings.xml index 7d617f734..498440ba8 100644 --- a/profile/src/main/res/values-uk/strings.xml +++ b/profile/src/main/res/values-uk/strings.xml @@ -6,7 +6,6 @@ Вийти Біо: %1$s Рік народження: %1$s - Вийти з профілю? Ви впевнені, що хочете вийти з профілю? Повний профіль Обмежений профіль @@ -21,7 +20,6 @@ Мова Перейти до повного профілю Перейти до обмеженого профілю - Ох, вибачте Готово Змінити зображення профілю Вибрати з галереї diff --git a/profile/src/main/res/values/strings.xml b/profile/src/main/res/values/strings.xml index ac4b4cbf3..ecc88ac6b 100644 --- a/profile/src/main/res/values/strings.xml +++ b/profile/src/main/res/values/strings.xml @@ -6,7 +6,6 @@ Log out Bio: %1$s Year of birth: %1$s - Log out? Are you sure you want to log out? Full profile Limited profile @@ -14,18 +13,17 @@ Edit Save Delete account - You must be over 13 years old to have a profile with full access to information + You must be over 13 years old to have a profile with full access to information. Year of birth Location About me Spoken language Switch to full profile Switch to limited profile - Oh, sorry Delete account Are you sure you want to delete your account? - To confirm this action you need to enter you account password + To confirm this action, please enter your account password. Yes, delete account Back to profile The password is incorrect. Please try again. @@ -38,9 +36,9 @@ Video settings Wi-fi only download Only download content when wi-fi is turned on - Leave profile? + Leave without saving? Leave Keep editing - Changes you have made may not be saved. + Changes you have made will be discarded. From 2da3666773c07022c5c2d4bc02850b453736fef7 Mon Sep 17 00:00:00 2001 From: Omer Habib <30689349+omerhabib26@users.noreply.github.com> Date: Wed, 27 Mar 2024 23:45:48 +0500 Subject: [PATCH 28/39] feat: Add Analytics events (#258) feat: Add Analytics events - Add analytics for the following modules - AppReview, Profile, WhatsNew, Course, Logistration, MainDashboard, Discovery - Restructure the analytics implementation to module level - Update the event names accordingly - Fix test cases fix: LEARNER-9876 --- .../java/org/openedx/app/AnalyticsManager.kt | 221 ++-------------- .../main/java/org/openedx/app/AppActivity.kt | 1 + .../main/java/org/openedx/app/AppAnalytics.kt | 34 ++- .../main/java/org/openedx/app/AppRouter.kt | 5 +- .../main/java/org/openedx/app/AppViewModel.kt | 12 +- .../main/java/org/openedx/app/MainFragment.kt | 11 +- .../java/org/openedx/app/MainViewModel.kt | 25 ++ .../main/java/org/openedx/app/di/AppModule.kt | 14 +- .../java/org/openedx/app/di/ScreenModule.kt | 92 +++++-- .../auth/presentation/AuthAnalytics.kt | 63 ++++- .../logistration/LogistrationFragment.kt | 28 +- .../logistration/LogistrationViewModel.kt | 65 +++++ .../restore/RestorePasswordFragment.kt | 4 +- .../restore/RestorePasswordViewModel.kt | 36 ++- .../presentation/signin/SignInViewModel.kt | 31 ++- .../presentation/signup/SignUpViewModel.kt | 46 +++- .../restore/RestorePasswordViewModelTest.kt | 73 +++--- .../signin/SignInViewModelTest.kt | 16 +- .../signup/SignUpViewModelTest.kt | 19 +- .../core/domain/model/VideoSettings.kt | 17 +- .../module/download/BaseDownloadViewModel.kt | 50 +++- .../core/presentation/CoreAnalytics.kt | 63 +++++ .../dialog/alert/ActionDialogFragment.kt | 39 ++- .../dialog/appreview/AppReviewAnalytics.kt | 29 +++ .../appreview/BaseAppReviewDialogFragment.kt | 54 +++- .../appreview/FeedbackDialogFragment.kt | 8 +- .../dialog/appreview/RateDialogFragment.kt | 15 +- .../appreview/ThankYouDialogFragment.kt | 12 +- .../settings/VideoQualityViewModel.kt | 25 +- .../java/org/openedx/core/ui/ComposeCommon.kt | 23 +- .../course/presentation/CourseAnalytics.kt | 240 ++++++++++++++++-- .../course/presentation/CourseRouter.kt | 2 +- .../calendarsync/CalendarSyncDialog.kt | 34 ++- .../container/CourseContainerFragment.kt | 61 ++++- .../container/CourseContainerViewModel.kt | 95 ++++++- .../presentation/dates/CourseDatesFragment.kt | 53 +++- .../dates/CourseDatesViewModel.kt | 82 +++++- .../detail/CourseDetailsFragment.kt | 9 +- .../detail/CourseDetailsViewModel.kt | 33 ++- .../handouts/HandoutsViewModel.kt | 20 +- .../handouts/HandoutsWebViewFragment.kt | 10 +- .../presentation/info/CourseInfoFragment.kt | 14 +- .../presentation/info/CourseInfoViewModel.kt | 44 +++- .../outline/CourseOutlineFragment.kt | 15 +- .../outline/CourseOutlineViewModel.kt | 52 +++- .../section/CourseSectionFragment.kt | 2 +- .../section/CourseSectionViewModel.kt | 34 ++- .../course/presentation/ui/CourseUI.kt | 11 +- .../container/CourseUnitContainerFragment.kt | 14 +- .../container/CourseUnitContainerViewModel.kt | 21 +- .../unit/video/BaseVideoViewModel.kt | 95 +++++++ .../unit/video/EncodedVideoUnitViewModel.kt | 49 +++- .../unit/video/VideoFullScreenFragment.kt | 36 ++- .../unit/video/VideoUnitFragment.kt | 10 +- .../unit/video/VideoUnitViewModel.kt | 11 +- .../presentation/unit/video/VideoViewModel.kt | 24 +- .../video/YoutubeVideoFullScreenFragment.kt | 16 +- .../unit/video/YoutubeVideoUnitFragment.kt | 21 +- .../videos/CourseVideoViewModel.kt | 10 +- .../videos/CourseVideosFragment.kt | 3 +- .../download/DownloadQueueViewModel.kt | 6 +- .../container/CourseContainerViewModelTest.kt | 30 ++- .../dates/CourseDatesViewModelTest.kt | 10 + .../detail/CourseDetailsViewModelTest.kt | 24 +- .../handouts/HandoutsViewModelTest.kt | 20 +- .../outline/CourseOutlineViewModelTest.kt | 16 +- .../section/CourseSectionViewModelTest.kt | 67 +++-- .../CourseUnitContainerViewModelTest.kt | 24 +- .../unit/video/VideoUnitViewModelTest.kt | 41 ++- .../unit/video/VideoViewModelTest.kt | 19 +- .../videos/CourseVideoViewModelTest.kt | 11 + .../dashboard/presentation/DashboardRouter.kt | 3 +- .../dashboard/DashboardFragment.kt | 3 +- .../presentation/program/ProgramFragment.kt | 2 + .../presentation/program/ProgramViewModel.kt | 3 +- .../presentation/DiscoveryAnalytics.kt | 22 +- .../presentation/NativeDiscoveryFragment.kt | 1 + .../presentation/NativeDiscoveryViewModel.kt | 15 +- .../presentation/WebViewDiscoveryFragment.kt | 13 +- .../presentation/WebViewDiscoveryViewModel.kt | 25 +- .../presentation/DiscussionAnalytics.kt | 4 +- .../profile/presentation/ProfileAnalytics.kt | 92 ++++++- .../delete/DeleteProfileViewModel.kt | 35 ++- .../presentation/edit/EditProfileViewModel.kt | 35 ++- .../presentation/profile/ProfileViewModel.kt | 41 ++- .../settings/video/VideoSettingsFragment.kt | 12 +- .../settings/video/VideoSettingsViewModel.kt | 44 +++- .../edit/EditProfileViewModelTest.kt | 4 + .../profile/ProfileViewModelTest.kt | 10 +- .../presentation/WhatsNewAnalytics.kt | 28 ++ .../presentation/whatsnew/WhatsNewFragment.kt | 47 ++-- .../whatsnew/WhatsNewViewModel.kt | 49 +++- .../openedx/whatsnew/WhatsNewViewModelTest.kt | 17 +- 93 files changed, 2349 insertions(+), 676 deletions(-) create mode 100644 auth/src/main/java/org/openedx/auth/presentation/logistration/LogistrationViewModel.kt create mode 100644 core/src/main/java/org/openedx/core/presentation/CoreAnalytics.kt create mode 100644 core/src/main/java/org/openedx/core/presentation/dialog/appreview/AppReviewAnalytics.kt create mode 100644 course/src/main/java/org/openedx/course/presentation/unit/video/BaseVideoViewModel.kt create mode 100644 whatsnew/src/main/java/org/openedx/whatsnew/presentation/WhatsNewAnalytics.kt diff --git a/app/src/main/java/org/openedx/app/AnalyticsManager.kt b/app/src/main/java/org/openedx/app/AnalyticsManager.kt index e474416dd..b8b6dbeff 100644 --- a/app/src/main/java/org/openedx/app/AnalyticsManager.kt +++ b/app/src/main/java/org/openedx/app/AnalyticsManager.kt @@ -6,17 +6,21 @@ import org.openedx.app.analytics.FirebaseAnalytics import org.openedx.app.analytics.SegmentAnalytics import org.openedx.auth.presentation.AuthAnalytics import org.openedx.core.config.Config +import org.openedx.core.presentation.CoreAnalytics +import org.openedx.core.presentation.dialog.appreview.AppReviewAnalytics import org.openedx.course.presentation.CourseAnalytics 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 +import org.openedx.whatsnew.presentation.WhatsNewAnalytics class AnalyticsManager( context: Context, config: Config, -) : DashboardAnalytics, AuthAnalytics, AppAnalytics, DiscoveryAnalytics, ProfileAnalytics, - CourseAnalytics, DiscussionAnalytics { +) : AppAnalytics, AppReviewAnalytics, AuthAnalytics, CoreAnalytics, CourseAnalytics, + DashboardAnalytics, DiscoveryAnalytics, DiscussionAnalytics, ProfileAnalytics, + WhatsNewAnalytics { private val services: ArrayList = arrayListOf() @@ -41,6 +45,12 @@ class AnalyticsManager( } } + override fun logEvent(event: String, params: Map) { + services.forEach { analytics -> + analytics.logEvent(event, params) + } + } + private fun setUserId(userId: Long) { services.forEach { analytics -> analytics.logUserId(userId) @@ -54,56 +64,12 @@ class AnalyticsManager( }) } - override fun userLoginEvent(method: String) { - logEvent(Event.USER_LOGIN, buildMap { - put(Key.METHOD.keyName, method) - }) - } - - override fun signUpClickedEvent() { - logEvent(Event.SIGN_UP_CLICKED) - } - - override fun createAccountClickedEvent(provider: String) { - logEvent(Event.CREATE_ACCOUNT_CLICKED, buildMap { - put(Key.PROVIDER.keyName, provider) - }) - } - - override fun registrationSuccessEvent(provider: String) { - logEvent(Event.REGISTRATION_SUCCESS, buildMap { put(Key.PROVIDER.keyName, provider) }) - } - - override fun forgotPasswordClickedEvent() { - logEvent(Event.FORGOT_PASSWORD_CLICKED) - } - - override fun resetPasswordClickedEvent(success: Boolean) { - logEvent(Event.RESET_PASSWORD_CLICKED, buildMap { put(Key.SUCCESS.keyName, success) }) - } - override fun logoutEvent(force: Boolean) { logEvent(Event.USER_LOGOUT, buildMap { put(Key.FORCE.keyName, force) }) } - override fun discoveryTabClickedEvent() { - logEvent(Event.DISCOVERY_TAB_CLICKED) - } - - override fun dashboardTabClickedEvent() { - logEvent(Event.DASHBOARD_TAB_CLICKED) - } - - override fun programsTabClickedEvent() { - logEvent(Event.PROGRAMS_TAB_CLICKED) - } - - override fun profileTabClickedEvent() { - logEvent(Event.PROFILE_TAB_CLICKED) - } - override fun setUserIdForSession(userId: Long) { setUserId(userId) } @@ -126,77 +92,8 @@ class AnalyticsManager( }) } - override fun profileEditClickedEvent() { - logEvent(Event.PROFILE_EDIT_CLICKED) - } - - override fun profileEditDoneClickedEvent() { - logEvent(Event.PROFILE_EDIT_DONE_CLICKED) - } - - override fun profileDeleteAccountClickedEvent() { - logEvent(Event.PROFILE_DELETE_ACCOUNT_CLICKED) - } - - override fun profileVideoSettingsClickedEvent() { - logEvent(Event.PROFILE_VIDEO_SETTINGS_CLICKED) - } - - override fun privacyPolicyClickedEvent() { - 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) - } - - override fun courseEnrollClickedEvent(courseId: String, courseName: String) { - logEvent(Event.COURSE_ENROLL_CLICKED, buildMap { - put(Key.COURSE_ID.keyName, courseId) - put(Key.COURSE_NAME.keyName, courseName) - }) - } - - override fun courseEnrollSuccessEvent(courseId: String, courseName: String) { - logEvent(Event.COURSE_ENROLL_SUCCESS, buildMap { - put(Key.COURSE_ID.keyName, courseId) - put(Key.COURSE_NAME.keyName, courseName) - }) - } - - override fun viewCourseClickedEvent(courseId: String, courseName: String) { - logEvent(Event.VIEW_COURSE_CLICKED, buildMap { - put(Key.COURSE_ID.keyName, courseId) - put(Key.COURSE_NAME.keyName, courseName) - }) - } - - override fun resumeCourseTappedEvent(courseId: String, courseName: String, blockId: String) { - logEvent(Event.RESUME_COURSE_TAPPED, buildMap { - put(Key.COURSE_ID.keyName, courseId) - put(Key.COURSE_NAME.keyName, courseName) - put(Key.BLOCK_ID.keyName, blockId) - }) - } - override fun sequentialClickedEvent( - courseId: String, courseName: String, blockId: String, blockName: String + courseId: String, courseName: String, blockId: String, blockName: String, ) { logEvent(Event.SEQUENTIAL_CLICKED, buildMap { put(Key.COURSE_ID.keyName, courseId) @@ -206,19 +103,8 @@ class AnalyticsManager( }) } - override fun verticalClickedEvent( - courseId: String, courseName: String, blockId: String, blockName: String - ) { - logEvent(Event.VERTICAL_CLICKED, buildMap { - put(Key.COURSE_ID.keyName, courseId) - put(Key.COURSE_NAME.keyName, courseName) - put(Key.BLOCK_ID.keyName, blockId) - put(Key.BLOCK_NAME.keyName, blockName) - }) - } - override fun nextBlockClickedEvent( - courseId: String, courseName: String, blockId: String, blockName: String + courseId: String, courseName: String, blockId: String, blockName: String, ) { logEvent(Event.NEXT_BLOCK_CLICKED, buildMap { put(Key.COURSE_ID.keyName, courseId) @@ -229,7 +115,7 @@ class AnalyticsManager( } override fun prevBlockClickedEvent( - courseId: String, courseName: String, blockId: String, blockName: String + courseId: String, courseName: String, blockId: String, blockName: String, ) { logEvent(Event.PREV_BLOCK_CLICKED, buildMap { put(Key.COURSE_ID.keyName, courseId) @@ -240,7 +126,7 @@ class AnalyticsManager( } override fun finishVerticalClickedEvent( - courseId: String, courseName: String, blockId: String, blockName: String + courseId: String, courseName: String, blockId: String, blockName: String, ) { logEvent(Event.FINISH_VERTICAL_CLICKED, buildMap { put(Key.COURSE_ID.keyName, courseId) @@ -251,7 +137,7 @@ class AnalyticsManager( } override fun finishVerticalNextClickedEvent( - courseId: String, courseName: String, blockId: String, blockName: String + courseId: String, courseName: String, blockId: String, blockName: String, ) { logEvent(Event.FINISH_VERTICAL_NEXT_CLICKED, buildMap { put(Key.COURSE_ID.keyName, courseId) @@ -268,41 +154,6 @@ class AnalyticsManager( }) } - override fun courseTabClickedEvent(courseId: String, courseName: String) { - logEvent(Event.COURSE_TAB_CLICKED, buildMap { - put(Key.COURSE_ID.keyName, courseId) - put(Key.COURSE_NAME.keyName, courseName) - }) - } - - override fun videoTabClickedEvent(courseId: String, courseName: String) { - logEvent(Event.VIDEO_TAB_CLICKED, buildMap { - put(Key.COURSE_ID.keyName, courseId) - put(Key.COURSE_NAME.keyName, courseName) - }) - } - - override fun discussionTabClickedEvent(courseId: String, courseName: String) { - logEvent(Event.DISCUSSION_TAB_CLICKED, buildMap { - put(Key.COURSE_ID.keyName, courseId) - put(Key.COURSE_NAME.keyName, courseName) - }) - } - - override fun datesTabClickedEvent(courseId: String, courseName: String) { - logEvent(Event.DATES_TAB_CLICKED, buildMap { - put(Key.COURSE_ID.keyName, courseId) - put(Key.COURSE_NAME.keyName, courseName) - }) - } - - override fun handoutsTabClickedEvent(courseId: String, courseName: String) { - logEvent(Event.HANDOUTS_TAB_CLICKED, buildMap { - put(Key.COURSE_ID.keyName, courseId) - put(Key.COURSE_NAME.keyName, courseName) - }) - } - override fun discussionAllPostsClickedEvent(courseId: String, courseName: String) { logEvent(Event.DISCUSSION_ALL_POSTS_CLICKED, buildMap { put(Key.COURSE_ID.keyName, courseId) @@ -318,7 +169,7 @@ class AnalyticsManager( } override fun discussionTopicClickedEvent( - courseId: String, courseName: String, topicId: String, topicName: String + courseId: String, courseName: String, topicId: String, topicName: String, ) { logEvent(Event.DISCUSSION_TOPIC_CLICKED, buildMap { put(Key.COURSE_ID.keyName, courseId) @@ -329,48 +180,19 @@ class AnalyticsManager( } } -private enum class Event(val eventName: String) { - USER_LOGIN("User_Login"), - SIGN_UP_CLICKED("Sign_up_Clicked"), - CREATE_ACCOUNT_CLICKED("Create_Account_Clicked"), - REGISTRATION_SUCCESS("Registration_Success"), +enum class Event(val eventName: String) { USER_LOGOUT("User_Logout"), - FORGOT_PASSWORD_CLICKED("Forgot_password_Clicked"), - RESET_PASSWORD_CLICKED("Reset_password_Clicked"), - DISCOVERY_TAB_CLICKED("Main_Discovery_tab_Clicked"), - DASHBOARD_TAB_CLICKED("Main_Dashboard_tab_Clicked"), - PROGRAMS_TAB_CLICKED("Main_Programs_tab_Clicked"), - PROFILE_TAB_CLICKED("Main_Profile_tab_Clicked"), DISCOVERY_SEARCH_BAR_CLICKED("Discovery_Search_Bar_Clicked"), DISCOVERY_COURSE_SEARCH("Discovery_Courses_Search"), DISCOVERY_COURSE_CLICKED("Discovery_Course_Clicked"), DASHBOARD_COURSE_CLICKED("Dashboard_Course_Clicked"), - PROFILE_EDIT_CLICKED("Profile_Edit_Clicked"), - PROFILE_EDIT_DONE_CLICKED("Profile_Edit_Done_Clicked"), - 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"), - VIEW_COURSE_CLICKED("View_Course_Clicked"), - RESUME_COURSE_TAPPED("Resume_Course_Tapped"), + SEQUENTIAL_CLICKED("Sequential_Clicked"), - VERTICAL_CLICKED("Vertical_Clicked"), NEXT_BLOCK_CLICKED("Next_Block_Clicked"), PREV_BLOCK_CLICKED("Prev_Block_Clicked"), FINISH_VERTICAL_CLICKED("Finish_Vertical_Clicked"), FINISH_VERTICAL_NEXT_CLICKED("Finish_Vertical_Next_section_Clicked"), FINISH_VERTICAL_BACK_CLICKED("Finish_Vertical_Back_to_outline_Clicked"), - 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"), DISCUSSION_TOPIC_CLICKED("Discussion_Topic_Clicked"), @@ -383,9 +205,6 @@ private enum class Key(val keyName: String) { BLOCK_NAME("block_name"), TOPIC_ID("topic_id"), TOPIC_NAME("topic_name"), - METHOD("method"), - SUCCESS("success"), - PROVIDER("provider"), FORCE("force"), LABEL("label"), COURSE_COUNT("courses_count"), diff --git a/app/src/main/java/org/openedx/app/AppActivity.kt b/app/src/main/java/org/openedx/app/AppActivity.kt index 26ba1a834..5ab0d0b0e 100644 --- a/app/src/main/java/org/openedx/app/AppActivity.kt +++ b/app/src/main/java/org/openedx/app/AppActivity.kt @@ -69,6 +69,7 @@ class AppActivity : AppCompatActivity(), InsetHolder, WindowSizeHolder { installSplashScreen() binding = ActivityAppBinding.inflate(layoutInflater) lifecycle.addObserver(viewModel) + viewModel.logAppLaunchEvent() setContentView(binding.root) val container = binding.rootLayout diff --git a/app/src/main/java/org/openedx/app/AppAnalytics.kt b/app/src/main/java/org/openedx/app/AppAnalytics.kt index 1114ccd8e..51278ef13 100644 --- a/app/src/main/java/org/openedx/app/AppAnalytics.kt +++ b/app/src/main/java/org/openedx/app/AppAnalytics.kt @@ -2,9 +2,33 @@ package org.openedx.app interface AppAnalytics { fun logoutEvent(force: Boolean) - fun discoveryTabClickedEvent() - fun dashboardTabClickedEvent() - fun programsTabClickedEvent() - fun profileTabClickedEvent() fun setUserIdForSession(userId: Long) -} \ No newline at end of file + fun logEvent(event: String, params: Map) +} + +enum class AppAnalyticsEvent(val eventName: String, val biValue: String) { + LAUNCH( + "Launch", + "edx.bi.app.launch" + ), + DISCOVER( + "MainDashboard:Discover", + "edx.bi.app.main_dashboard.discover" + ), + MY_COURSES( + "MainDashboard:My Courses", + "edx.bi.app.main_dashboard.my_course" + ), + MY_PROGRAMS( + "MainDashboard:My Programs", + "edx.bi.app.main_dashboard.my_program" + ), + PROFILE( + "MainDashboard:Profile", + "edx.bi.app.main_dashboard.profile" + ), +} + +enum class AppAnalyticsKey(val key: String) { + NAME("name"), +} diff --git a/app/src/main/java/org/openedx/app/AppRouter.kt b/app/src/main/java/org/openedx/app/AppRouter.kt index daf3662f0..1e6875b4e 100644 --- a/app/src/main/java/org/openedx/app/AppRouter.kt +++ b/app/src/main/java/org/openedx/app/AppRouter.kt @@ -134,11 +134,12 @@ class AppRouter : AuthRouter, DiscoveryRouter, DashboardRouter, CourseRouter, Di override fun navigateToCourseOutline( fm: FragmentManager, courseId: String, - courseTitle: String + courseTitle: String, + enrollmentMode: String, ) { replaceFragmentWithBackStack( fm, - CourseContainerFragment.newInstance(courseId, courseTitle) + CourseContainerFragment.newInstance(courseId, courseTitle, enrollmentMode) ) } diff --git a/app/src/main/java/org/openedx/app/AppViewModel.kt b/app/src/main/java/org/openedx/app/AppViewModel.kt index 84b8f8081..1febbd15a 100644 --- a/app/src/main/java/org/openedx/app/AppViewModel.kt +++ b/app/src/main/java/org/openedx/app/AppViewModel.kt @@ -20,7 +20,7 @@ class AppViewModel( private val room: RoomDatabase, private val preferencesManager: CorePreferences, private val dispatcher: CoroutineDispatcher, - private val analytics: AppAnalytics + private val analytics: AppAnalytics, ) : BaseViewModel() { private val _logoutUser = SingleEventLiveData() @@ -51,10 +51,18 @@ class AppViewModel( } } + fun logAppLaunchEvent() { + analytics.logEvent( + event = AppAnalyticsEvent.LAUNCH.eventName, + params = buildMap { + put(AppAnalyticsKey.NAME.key, AppAnalyticsEvent.LAUNCH.biValue) + } + ) + } + private fun setUserId() { preferencesManager.user?.let { analytics.setUserIdForSession(it.id) } } - } diff --git a/app/src/main/java/org/openedx/app/MainFragment.kt b/app/src/main/java/org/openedx/app/MainFragment.kt index 8534eaffe..b656d3b46 100644 --- a/app/src/main/java/org/openedx/app/MainFragment.kt +++ b/app/src/main/java/org/openedx/app/MainFragment.kt @@ -25,7 +25,6 @@ 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 val config by inject() @@ -49,27 +48,29 @@ class MainFragment : Fragment(R.layout.fragment_main) { binding.bottomNavView.setOnItemSelectedListener { when (it.itemId) { R.id.fragmentHome -> { - analytics.discoveryTabClickedEvent() + viewModel.logDiscoveryTabClickedEvent() binding.viewPager.setCurrentItem(0, false) } R.id.fragmentDashboard -> { - analytics.dashboardTabClickedEvent() + viewModel.logMyCoursesTabClickedEvent() binding.viewPager.setCurrentItem(1, false) } R.id.fragmentPrograms -> { - analytics.programsTabClickedEvent() + viewModel.logMyProgramsTabClickedEvent() binding.viewPager.setCurrentItem(2, false) } R.id.fragmentProfile -> { - analytics.profileTabClickedEvent() + viewModel.logProfileTabClickedEvent() binding.viewPager.setCurrentItem(3, false) } } true } + // Trigger click event for the first tab on initial load + binding.bottomNavView.selectedItemId = binding.bottomNavView.selectedItemId viewModel.isBottomBarEnabled.observe(viewLifecycleOwner) { isBottomBarEnabled -> enableBottomBar(isBottomBarEnabled) diff --git a/app/src/main/java/org/openedx/app/MainViewModel.kt b/app/src/main/java/org/openedx/app/MainViewModel.kt index 3b36cc2be..1da2f64f5 100644 --- a/app/src/main/java/org/openedx/app/MainViewModel.kt +++ b/app/src/main/java/org/openedx/app/MainViewModel.kt @@ -18,6 +18,7 @@ import org.openedx.dashboard.notifier.DashboardNotifier class MainViewModel( private val config: Config, private val notifier: DashboardNotifier, + private val analytics: AppAnalytics, ) : BaseViewModel() { private val _isBottomBarEnabled = MutableLiveData(true) @@ -44,4 +45,28 @@ class MainViewModel( fun enableBottomBar(enable: Boolean) { _isBottomBarEnabled.value = enable } + + fun logDiscoveryTabClickedEvent() { + logEvent(AppAnalyticsEvent.DISCOVER) + } + + fun logMyCoursesTabClickedEvent() { + logEvent(AppAnalyticsEvent.MY_COURSES) + } + + fun logMyProgramsTabClickedEvent() { + logEvent(AppAnalyticsEvent.MY_PROGRAMS) + } + + fun logProfileTabClickedEvent() { + logEvent(AppAnalyticsEvent.PROFILE) + } + + private fun logEvent(event: AppAnalyticsEvent) { + analytics.logEvent(event.eventName, + buildMap { + put(AppAnalyticsKey.NAME.key, event.biValue) + } + ) + } } 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 f798d8559..2952e16c8 100644 --- a/app/src/main/java/org/openedx/app/di/AppModule.kt +++ b/app/src/main/java/org/openedx/app/di/AppModule.kt @@ -33,6 +33,8 @@ 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.CoreAnalytics +import org.openedx.core.presentation.dialog.appreview.AppReviewAnalytics import org.openedx.core.presentation.dialog.appreview.AppReviewManager import org.openedx.core.presentation.global.AppData import org.openedx.core.presentation.global.WhatsNewGlobalManager @@ -64,6 +66,7 @@ import org.openedx.profile.system.notifier.ProfileNotifier import org.openedx.whatsnew.WhatsNewManager import org.openedx.whatsnew.WhatsNewRouter import org.openedx.whatsnew.data.storage.WhatsNewPreferences +import org.openedx.whatsnew.presentation.WhatsNewAnalytics val appModule = module { @@ -161,13 +164,16 @@ val appModule = module { single { get() } single { AnalyticsManager(get(), get()) } - single { get() } - single { get() } single { get() } - single { get() } - single { get() } + single { get() } + single { get() } + single { get() } single { get() } + single { get() } + single { get() } single { get() } + single { get() } + single { get() } factory { AgreementProvider(get(), get()) } factory { FacebookAuthHelper() } 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 8c0add1c3..4b5bf341f 100644 --- a/app/src/main/java/org/openedx/app/di/ScreenModule.kt +++ b/app/src/main/java/org/openedx/app/di/ScreenModule.kt @@ -7,6 +7,7 @@ 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.logistration.LogistrationViewModel import org.openedx.auth.presentation.restore.RestorePasswordViewModel import org.openedx.auth.presentation.signin.SignInViewModel import org.openedx.auth.presentation.signup.SignUpViewModel @@ -24,6 +25,7 @@ import org.openedx.course.presentation.outline.CourseOutlineViewModel 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.BaseVideoViewModel import org.openedx.course.presentation.unit.video.EncodedVideoUnitViewModel import org.openedx.course.presentation.unit.video.VideoUnitViewModel import org.openedx.course.presentation.unit.video.VideoViewModel @@ -60,11 +62,21 @@ import org.openedx.whatsnew.presentation.whatsnew.WhatsNewViewModel val screenModule = module { viewModel { AppViewModel(get(), get(), get(), get(), get(named("IODispatcher")), get()) } - viewModel { MainViewModel(get(), get()) } + viewModel { MainViewModel(get(), get(), get()) } factory { AuthRepository(get(), get(), get()) } factory { AuthInteractor(get()) } factory { Validator() } + + viewModel { (courseId: String) -> + LogistrationViewModel( + courseId, + get(), + get(), + get(), + ) + } + viewModel { (courseId: String?, infoType: String?) -> SignInViewModel( get(), @@ -84,7 +96,19 @@ val screenModule = module { } viewModel { (courseId: String?, infoType: String?) -> - SignUpViewModel(get(), get(), get(), get(), get(), get(), get(), get(), get(), courseId, infoType) + SignUpViewModel( + get(), + get(), + get(), + get(), + get(), + get(), + get(), + get(), + get(), + courseId, + infoType + ) } viewModel { RestorePasswordViewModel(get(), get(), get(), get()) } @@ -97,11 +121,12 @@ val screenModule = module { viewModel { NativeDiscoveryViewModel(get(), get(), get(), get(), get(), get(), get()) } viewModel { (querySearch: String) -> WebViewDiscoveryViewModel( + querySearch, + get(), get(), get(), get(), get(), - querySearch ) } @@ -123,15 +148,18 @@ val screenModule = module { ) } viewModel { (account: Account) -> EditProfileViewModel(get(), get(), get(), get(), account) } - viewModel { VideoSettingsViewModel(get(), get()) } - viewModel { (qualityType: String) -> VideoQualityViewModel(get(), get(), qualityType) } - viewModel { DeleteProfileViewModel(get(), get(), get(), get()) } + viewModel { VideoSettingsViewModel(get(), get(), get(), get()) } + viewModel { (qualityType: String) -> VideoQualityViewModel(qualityType, get(), get(), get()) } + viewModel { DeleteProfileViewModel(get(), get(), get(), get(), get()) } viewModel { (username: String) -> AnothersProfileViewModel(get(), get(), username) } single { CourseRepository(get(), get(), get(), get()) } factory { CourseInteractor(get()) } viewModel { (pathId: String, infoType: String) -> CourseInfoViewModel( + pathId, + infoType, + get(), get(), get(), get(), @@ -139,8 +167,6 @@ val screenModule = module { get(), get(), get(), - pathId, - infoType ) } viewModel { (courseId: String) -> @@ -155,10 +181,11 @@ val screenModule = module { get() ) } - viewModel { (courseId: String, courseTitle: String) -> + viewModel { (courseId: String, courseTitle: String, enrollmentMode: String) -> CourseContainerViewModel( courseId, courseTitle, + enrollmentMode, get(), get(), get(), @@ -181,11 +208,14 @@ val screenModule = module { get(), get(), get(), - get() + get(), + get(), ) } viewModel { (courseId: String) -> CourseSectionViewModel( + courseId, + get(), get(), get(), get(), @@ -194,16 +224,16 @@ val screenModule = module { get(), get(), get(), - courseId ) } - viewModel { (courseId: String) -> + viewModel { (courseId: String, unitId: String) -> CourseUnitContainerViewModel( + courseId, + unitId, get(), get(), get(), get(), - courseId ) } viewModel { (courseId: String) -> @@ -218,11 +248,22 @@ val screenModule = module { get(), get(), get(), + get(), + get(), + ) + } + viewModel { (courseId: String) -> BaseVideoViewModel(courseId, get()) } + viewModel { (courseId: String) -> VideoViewModel(courseId, get(), get(), get(), get()) } + viewModel { (courseId: String) -> + VideoUnitViewModel( + courseId, + 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, blockId: String) -> EncodedVideoUnitViewModel( courseId, @@ -232,14 +273,17 @@ val screenModule = module { get(), get(), get(), - get() + get(), + get(), ) } - viewModel { (courseId: String, courseName: String, isSelfPaced: Boolean) -> + viewModel { (courseId: String, courseName: String, isSelfPaced: Boolean, enrollmentMode: String) -> CourseDatesViewModel( courseId, courseName, isSelfPaced, + enrollmentMode, + get(), get(), get(), get(), @@ -252,9 +296,10 @@ val screenModule = module { viewModel { (courseId: String, handoutsType: String) -> HandoutsViewModel( courseId, - get(), handoutsType, - get() + get(), + get(), + get(), ) } viewModel { CourseSearchViewModel(get(), get(), get(), get(), get()) } @@ -303,7 +348,11 @@ val screenModule = module { WhatsNewViewModel( courseId, infoType, - get() + get(), + get(), + get(), + get(), + get(), ) } @@ -313,7 +362,8 @@ val screenModule = module { get(), get(), get(), - get() + get(), + get(), ) } viewModel { HtmlUnitViewModel(get(), get(), get(), get()) } diff --git a/auth/src/main/java/org/openedx/auth/presentation/AuthAnalytics.kt b/auth/src/main/java/org/openedx/auth/presentation/AuthAnalytics.kt index 669c749b4..e87ad9674 100644 --- a/auth/src/main/java/org/openedx/auth/presentation/AuthAnalytics.kt +++ b/auth/src/main/java/org/openedx/auth/presentation/AuthAnalytics.kt @@ -1,11 +1,60 @@ package org.openedx.auth.presentation interface AuthAnalytics { - fun userLoginEvent(method: String) - fun signUpClickedEvent() - fun createAccountClickedEvent(provider: String) - fun registrationSuccessEvent(provider: String) - fun forgotPasswordClickedEvent() - fun resetPasswordClickedEvent(success: Boolean) fun setUserIdForSession(userId: Long) -} \ No newline at end of file + fun logEvent(event: String, params: Map) +} + +enum class AuthAnalyticsEvent(val eventName: String, val biValue: String) { + DISCOVERY_COURSES_SEARCH( + "Logistration:Courses Search", + "edx.bi.app.logistration.courses_search" + ), + EXPLORE_ALL_COURSES( + "Logistration:Explore All Courses", + "edx.bi.app.logistration.explore.all.courses" + ), + REGISTER_CLICKED( + "Logistration:Register Clicked", + "edx.bi.app.logistration.register.clicked" + ), + CREATE_ACCOUNT_CLICKED( + "Logistration:Create Account Clicked", + "edx.bi.app.logistration.user.create_account.clicked" + ), + REGISTER_SUCCESS( + "Logistration:Register Success", + "edx.bi.app.user.register.success" + ), + SIGN_IN_CLICKED( + "Logistration:Sign In Clicked", + "edx.bi.app.logistration.signin.clicked" + ), + USER_SIGN_IN_CLICKED( + "Logistration:User Sign In Clicked", + "edx.bi.app.logistration.user.signin.clicked" + ), + SIGN_IN_SUCCESS( + "Logistration:Sign In Success", + "edx.bi.app.user.signin.success" + ), + FORGOT_PASSWORD_CLICKED( + "Logistration:Forgot Password Clicked", + "edx.bi.app.logistration.forgot_password.clicked" + ), + RESET_PASSWORD_CLICKED( + "Logistration:Reset Password Clicked", + "edx.bi.app.user.reset_password.clicked" + ), + RESET_PASSWORD_SUCCESS( + "Logistration:Reset Password Success", + "edx.bi.app.user.reset_password.success" + ), +} + +enum class AuthAnalyticsKey(val key: String) { + NAME("name"), + SEARCH_QUERY("search_query"), + SUCCESS("success"), + METHOD("method"), +} diff --git a/auth/src/main/java/org/openedx/auth/presentation/logistration/LogistrationFragment.kt b/auth/src/main/java/org/openedx/auth/presentation/logistration/LogistrationFragment.kt index eb63bbc19..738364c34 100644 --- a/auth/src/main/java/org/openedx/auth/presentation/logistration/LogistrationFragment.kt +++ b/auth/src/main/java/org/openedx/auth/presentation/logistration/LogistrationFragment.kt @@ -38,10 +38,9 @@ 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.koin.androidx.viewmodel.ext.android.viewModel +import org.koin.core.parameter.parametersOf import org.openedx.auth.R -import org.openedx.auth.presentation.AuthRouter -import org.openedx.core.config.Config import org.openedx.core.ui.AuthButtonsPanel import org.openedx.core.ui.SearchBar import org.openedx.core.ui.displayCutoutForLandscape @@ -53,8 +52,9 @@ import org.openedx.core.ui.theme.compose.LogistrationLogoView class LogistrationFragment : Fragment() { - private val router: AuthRouter by inject() - private val config by inject() + private val viewModel: LogistrationViewModel by viewModel { + parametersOf(arguments?.getString(ARG_COURSE_ID, "") ?: "") + } override fun onCreateView( inflater: LayoutInflater, @@ -64,27 +64,15 @@ class LogistrationFragment : Fragment() { setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed) setContent { OpenEdXTheme { - val courseId = arguments?.getString(ARG_COURSE_ID, "") - val isDiscoveryTypeWebView = config.getDiscoveryConfig().isViewTypeWebView() LogistrationScreen( onSignInClick = { - router.navigateToSignIn(parentFragmentManager, courseId, null) + viewModel.navigateToSignIn(parentFragmentManager) }, onRegisterClick = { - router.navigateToSignUp(parentFragmentManager, courseId, null) + viewModel.navigateToSignUp(parentFragmentManager) }, onSearchClick = { querySearch -> - if (isDiscoveryTypeWebView) { - router.navigateToWebDiscoverCourses( - parentFragmentManager, - querySearch - ) - } else { - router.navigateToNativeDiscoverCourses( - parentFragmentManager, - querySearch - ) - } + viewModel.navigateToDiscovery(parentFragmentManager, querySearch) } ) } diff --git a/auth/src/main/java/org/openedx/auth/presentation/logistration/LogistrationViewModel.kt b/auth/src/main/java/org/openedx/auth/presentation/logistration/LogistrationViewModel.kt new file mode 100644 index 000000000..e48a5e8be --- /dev/null +++ b/auth/src/main/java/org/openedx/auth/presentation/logistration/LogistrationViewModel.kt @@ -0,0 +1,65 @@ +package org.openedx.auth.presentation.logistration + +import androidx.fragment.app.FragmentManager +import org.openedx.auth.presentation.AuthAnalytics +import org.openedx.auth.presentation.AuthAnalyticsEvent +import org.openedx.auth.presentation.AuthAnalyticsKey +import org.openedx.auth.presentation.AuthRouter +import org.openedx.core.BaseViewModel +import org.openedx.core.config.Config +import org.openedx.core.extension.takeIfNotEmpty + +class LogistrationViewModel( + private val courseId: String, + private val router: AuthRouter, + private val config: Config, + private val analytics: AuthAnalytics, +) : BaseViewModel() { + + private val discoveryTypeWebView get() = config.getDiscoveryConfig().isViewTypeWebView() + + fun navigateToSignIn(parentFragmentManager: FragmentManager) { + router.navigateToSignIn(parentFragmentManager, courseId, null) + logEvent(AuthAnalyticsEvent.SIGN_IN_CLICKED) + } + + fun navigateToSignUp(parentFragmentManager: FragmentManager) { + router.navigateToSignUp(parentFragmentManager, courseId, null) + logEvent(AuthAnalyticsEvent.REGISTER_CLICKED) + } + + fun navigateToDiscovery(parentFragmentManager: FragmentManager, querySearch: String) { + if (discoveryTypeWebView) { + router.navigateToWebDiscoverCourses( + parentFragmentManager, + querySearch + ) + } else { + router.navigateToNativeDiscoverCourses( + parentFragmentManager, + querySearch + ) + } + querySearch.takeIfNotEmpty()?.let { + logEvent( + event = AuthAnalyticsEvent.DISCOVERY_COURSES_SEARCH, + params = buildMap { + put(AuthAnalyticsKey.SEARCH_QUERY.key, querySearch) + } + ) + } ?: logEvent(event = AuthAnalyticsEvent.EXPLORE_ALL_COURSES) + } + + private fun logEvent( + event: AuthAnalyticsEvent, + params: Map = emptyMap(), + ) { + analytics.logEvent( + event = event.eventName, + params = buildMap { + put(AuthAnalyticsKey.NAME.key, event.biValue) + putAll(params) + } + ) + } +} 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 9b0740652..18cf169bc 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 @@ -81,7 +81,7 @@ class RestorePasswordFragment : Fragment() { override fun onCreateView( inflater: LayoutInflater, container: ViewGroup?, - savedInstanceState: Bundle? + savedInstanceState: Bundle?, ) = ComposeView(requireContext()).apply { setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed) setContent { @@ -123,7 +123,7 @@ private fun RestorePasswordScreen( uiState: RestorePasswordUIState, uiMessage: UIMessage?, onBackClick: () -> Unit, - onRestoreButtonClick: (String) -> Unit + onRestoreButtonClick: (String) -> Unit, ) { val scaffoldState = rememberScaffoldState() val scrollState = rememberScrollState() 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 427f2f263..b21c694da 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 @@ -6,6 +6,8 @@ import androidx.lifecycle.viewModelScope import kotlinx.coroutines.launch import org.openedx.auth.domain.interactor.AuthInteractor import org.openedx.auth.presentation.AuthAnalytics +import org.openedx.auth.presentation.AuthAnalyticsEvent +import org.openedx.auth.presentation.AuthAnalyticsKey import org.openedx.core.BaseViewModel import org.openedx.core.R import org.openedx.core.SingleEventLiveData @@ -21,7 +23,7 @@ class RestorePasswordViewModel( private val interactor: AuthInteractor, private val resourceManager: ResourceManager, private val analytics: AuthAnalytics, - private val appUpgradeNotifier: AppUpgradeNotifier + private val appUpgradeNotifier: AppUpgradeNotifier, ) : BaseViewModel() { private val _uiState = MutableLiveData() @@ -41,28 +43,29 @@ class RestorePasswordViewModel( } fun passwordReset(email: String) { + logEvent(AuthAnalyticsEvent.RESET_PASSWORD_CLICKED) _uiState.value = RestorePasswordUIState.Loading viewModelScope.launch { try { if (email.isNotEmpty() && email.isEmailValid()) { if (interactor.passwordReset(email)) { _uiState.value = RestorePasswordUIState.Success(email) - analytics.resetPasswordClickedEvent(true) + logResetPasswordEvent(true) } else { _uiState.value = RestorePasswordUIState.Initial _uiMessage.value = UIMessage.SnackBarMessage(resourceManager.getString(R.string.core_error_unknown_error)) - analytics.resetPasswordClickedEvent(false) + logResetPasswordEvent(false) } } else { _uiState.value = RestorePasswordUIState.Initial _uiMessage.value = UIMessage.SnackBarMessage(resourceManager.getString(org.openedx.auth.R.string.auth_invalid_email)) - analytics.resetPasswordClickedEvent(false) + logResetPasswordEvent(false) } } catch (e: Exception) { _uiState.value = RestorePasswordUIState.Initial - analytics.resetPasswordClickedEvent(false) + logResetPasswordEvent(false) if (e is EdxError.ValidationException) { _uiMessage.value = UIMessage.SnackBarMessage(e.error) } else if (e.isInternetError()) { @@ -84,4 +87,25 @@ class RestorePasswordViewModel( } } -} \ No newline at end of file + private fun logResetPasswordEvent(success: Boolean) { + logEvent( + event = AuthAnalyticsEvent.RESET_PASSWORD_SUCCESS, + params = buildMap { + put(AuthAnalyticsKey.SUCCESS.key, success) + } + ) + } + + private fun logEvent( + event: AuthAnalyticsEvent, + params: Map = emptyMap(), + ) { + analytics.logEvent( + event = event.eventName, + params = buildMap { + put(AuthAnalyticsKey.NAME.key, event.biValue) + putAll(params) + } + ) + } +} 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 cf1c6afa5..7ebc5a569 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 @@ -17,6 +17,8 @@ import org.openedx.auth.domain.interactor.AuthInteractor import org.openedx.auth.domain.model.SocialAuthResponse import org.openedx.auth.presentation.AgreementProvider import org.openedx.auth.presentation.AuthAnalytics +import org.openedx.auth.presentation.AuthAnalyticsEvent +import org.openedx.auth.presentation.AuthAnalyticsKey import org.openedx.auth.presentation.AuthRouter import org.openedx.auth.presentation.sso.OAuthHelper import org.openedx.core.BaseViewModel @@ -78,6 +80,7 @@ class SignInViewModel( } fun login(username: String, password: String) { + logEvent(AuthAnalyticsEvent.USER_SIGN_IN_CLICKED) if (!validator.isEmailOrUserNameValid(username)) { _uiMessage.value = UIMessage.SnackBarMessage(resourceManager.getString(R.string.auth_invalid_email_username)) @@ -95,7 +98,15 @@ class SignInViewModel( interactor.login(username, password) _uiState.update { it.copy(loginSuccess = true) } setUserId() - analytics.userLoginEvent(AuthType.PASSWORD.methodName) + logEvent( + AuthAnalyticsEvent.SIGN_IN_SUCCESS, + buildMap { + put( + AuthAnalyticsKey.METHOD.key, + AuthType.PASSWORD.methodName.lowercase() + ) + } + ) } catch (e: Exception) { if (e is EdxError.InvalidGrantException) { _uiMessage.value = @@ -135,12 +146,12 @@ class SignInViewModel( fun navigateToSignUp(parentFragmentManager: FragmentManager) { router.navigateToSignUp(parentFragmentManager, null, null) - analytics.signUpClickedEvent() + logEvent(AuthAnalyticsEvent.REGISTER_CLICKED) } fun navigateToForgotPassword(parentFragmentManager: FragmentManager) { router.navigateToRestorePassword(parentFragmentManager) - analytics.forgotPasswordClickedEvent() + logEvent(AuthAnalyticsEvent.FORGOT_PASSWORD_CLICKED) } override fun onCleared() { @@ -158,7 +169,6 @@ class SignInViewModel( logger.d { "Social login (${authType.methodName}) success" } _uiState.update { it.copy(loginSuccess = true) } setUserId() - analytics.userLoginEvent(authType.methodName) _uiState.update { it.copy(showProgress = false) } } } @@ -217,4 +227,17 @@ class SignInViewModel( } } } + + private fun logEvent( + event: AuthAnalyticsEvent, + params: Map = emptyMap(), + ) { + analytics.logEvent( + event = event.eventName, + params = buildMap { + put(AuthAnalyticsKey.NAME.key, event.biValue) + putAll(params) + } + ) + } } 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 2b7cdc09f..8fafe40ff 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 @@ -17,6 +17,8 @@ import org.openedx.auth.domain.interactor.AuthInteractor import org.openedx.auth.domain.model.SocialAuthResponse import org.openedx.auth.presentation.AgreementProvider import org.openedx.auth.presentation.AuthAnalytics +import org.openedx.auth.presentation.AuthAnalyticsEvent +import org.openedx.auth.presentation.AuthAnalyticsKey import org.openedx.auth.presentation.AuthRouter import org.openedx.auth.presentation.sso.OAuthHelper import org.openedx.core.ApiConstants @@ -105,8 +107,10 @@ class SignUpViewModel( val agreementFields = mutableListOf() val agreementText = agreementProvider.getAgreement(isSignIn = false) if (agreementText != null) { - val honourCode = allFields.find { it.name == ApiConstants.RegistrationFields.HONOR_CODE } - val marketingEmails = allFields.find { it.name == ApiConstants.RegistrationFields.MARKETING_EMAILS } + val honourCode = + allFields.find { it.name == ApiConstants.RegistrationFields.HONOR_CODE } + val marketingEmails = + allFields.find { it.name == ApiConstants.RegistrationFields.MARKETING_EMAILS } mutableAllFields.remove(honourCode) requiredFields.addAll(mutableAllFields.filter { it.required }) optionalFields.addAll(mutableAllFields.filter { !it.required }) @@ -129,9 +133,9 @@ class SignUpViewModel( } fun register() { - analytics.createAccountClickedEvent("") + logEvent(AuthAnalyticsEvent.CREATE_ACCOUNT_CLICKED) val mapFields = uiState.value.allFields.associate { it.name to it.placeholder } + - mapOf(ApiConstants.RegistrationFields.HONOR_CODE to true.toString()) + mapOf(ApiConstants.RegistrationFields.HONOR_CODE to true.toString()) val resultMap = mapFields.toMutableMap() uiState.value.allFields.filter { !it.required }.forEach { (k, _) -> if (mapFields[k].isNullOrEmpty()) { @@ -154,7 +158,16 @@ class SignUpViewModel( resultMap[ApiConstants.CLIENT_ID] = config.getOAuthClientId() } interactor.register(resultMap.toMap()) - analytics.registrationSuccessEvent(socialAuth?.authType?.postfix.orEmpty()) + logEvent( + event = AuthAnalyticsEvent.REGISTER_SUCCESS, + params = buildMap { + put( + AuthAnalyticsKey.METHOD.key, + (socialAuth?.authType?.methodName + ?: AuthType.PASSWORD.methodName).lowercase() + ) + } + ) if (socialAuth == null) { interactor.login( resultMap.getValue(ApiConstants.EMAIL), @@ -226,7 +239,15 @@ class SignUpViewModel( updateFields(fields) }.onSuccess { setUserId() - analytics.userLoginEvent(socialAuth.authType.methodName) + logEvent( + AuthAnalyticsEvent.SIGN_IN_SUCCESS, + buildMap { + put( + AuthAnalyticsKey.METHOD.key, + socialAuth.authType.methodName.lowercase() + ) + } + ) _uiState.update { it.copy(successLogin = true) } logger.d { "Social login (${socialAuth.authType.methodName}) success" } } @@ -279,4 +300,17 @@ class SignUpViewModel( } } } + + private fun logEvent( + event: AuthAnalyticsEvent, + params: Map = emptyMap(), + ) { + analytics.logEvent( + event = event.eventName, + params = buildMap { + put(AuthAnalyticsKey.NAME.key, event.biValue) + putAll(params) + } + ) + } } 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 e80b93db7..4c92b317f 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 @@ -1,12 +1,6 @@ package org.openedx.auth.presentation.restore import androidx.arch.core.executor.testing.InstantTaskExecutorRule -import org.openedx.auth.domain.interactor.AuthInteractor -import org.openedx.auth.presentation.AuthAnalytics -import org.openedx.core.R -import org.openedx.core.UIMessage -import org.openedx.core.system.EdxError -import org.openedx.core.system.ResourceManager import io.mockk.coEvery import io.mockk.coVerify import io.mockk.every @@ -15,13 +9,23 @@ import io.mockk.verify import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.emptyFlow -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.* +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.auth.domain.interactor.AuthInteractor +import org.openedx.auth.presentation.AuthAnalytics +import org.openedx.core.R +import org.openedx.core.UIMessage +import org.openedx.core.system.EdxError +import org.openedx.core.system.ResourceManager import org.openedx.core.system.notifier.AppUpgradeNotifier import java.net.UnknownHostException @@ -66,13 +70,14 @@ class RestorePasswordViewModelTest { @Test fun `passwordReset empty email validation error`() = runTest { - val viewModel = RestorePasswordViewModel(interactor, resourceManager, analytics, appUpgradeNotifier) + val viewModel = + RestorePasswordViewModel(interactor, resourceManager, analytics, appUpgradeNotifier) coEvery { interactor.passwordReset(emptyEmail) } returns true - every { analytics.resetPasswordClickedEvent(false) } returns Unit + every { analytics.logEvent(any(), any()) } returns Unit viewModel.passwordReset(emptyEmail) advanceUntilIdle() coVerify(exactly = 0) { interactor.passwordReset(any()) } - verify(exactly = 1) { analytics.resetPasswordClickedEvent(false) } + verify(exactly = 2) { analytics.logEvent(any(), any()) } verify(exactly = 1) { appUpgradeNotifier.notifier } val message = viewModel.uiMessage.value as? UIMessage.SnackBarMessage @@ -83,13 +88,14 @@ class RestorePasswordViewModelTest { @Test fun `passwordReset invalid email validation error`() = runTest { - val viewModel = RestorePasswordViewModel(interactor, resourceManager, analytics, appUpgradeNotifier) + val viewModel = + RestorePasswordViewModel(interactor, resourceManager, analytics, appUpgradeNotifier) coEvery { interactor.passwordReset(invalidEmail) } returns true - every { analytics.resetPasswordClickedEvent(false) } returns Unit + every { analytics.logEvent(any(), any()) } returns Unit viewModel.passwordReset(invalidEmail) advanceUntilIdle() coVerify(exactly = 0) { interactor.passwordReset(any()) } - verify(exactly = 1) { analytics.resetPasswordClickedEvent(false) } + verify(exactly = 2) { analytics.logEvent(any(), any()) } verify(exactly = 1) { appUpgradeNotifier.notifier } val message = viewModel.uiMessage.value as? UIMessage.SnackBarMessage @@ -100,13 +106,14 @@ class RestorePasswordViewModelTest { @Test fun `passwordReset validation error`() = runTest { - val viewModel = RestorePasswordViewModel(interactor, resourceManager, analytics, appUpgradeNotifier) + val viewModel = + RestorePasswordViewModel(interactor, resourceManager, analytics, appUpgradeNotifier) coEvery { interactor.passwordReset(correctEmail) } throws EdxError.ValidationException("error") - every { analytics.resetPasswordClickedEvent(false) } returns Unit + every { analytics.logEvent(any(), any()) } returns Unit viewModel.passwordReset(correctEmail) advanceUntilIdle() coVerify(exactly = 1) { interactor.passwordReset(any()) } - verify(exactly = 1) { analytics.resetPasswordClickedEvent(false) } + verify(exactly = 2) { analytics.logEvent(any(), any()) } verify(exactly = 1) { appUpgradeNotifier.notifier } val message = viewModel.uiMessage.value as? UIMessage.SnackBarMessage @@ -117,13 +124,14 @@ class RestorePasswordViewModelTest { @Test fun `passwordReset no internet error`() = runTest { - val viewModel = RestorePasswordViewModel(interactor, resourceManager, analytics, appUpgradeNotifier) + val viewModel = + RestorePasswordViewModel(interactor, resourceManager, analytics, appUpgradeNotifier) coEvery { interactor.passwordReset(correctEmail) } throws UnknownHostException() - every { analytics.resetPasswordClickedEvent(false) } returns Unit + every { analytics.logEvent(any(), any()) } returns Unit viewModel.passwordReset(correctEmail) advanceUntilIdle() coVerify(exactly = 1) { interactor.passwordReset(any()) } - verify(exactly = 1) { analytics.resetPasswordClickedEvent(false) } + verify(exactly = 2) { analytics.logEvent(any(), any()) } verify(exactly = 1) { appUpgradeNotifier.notifier } val message = viewModel.uiMessage.value as? UIMessage.SnackBarMessage @@ -134,13 +142,14 @@ class RestorePasswordViewModelTest { @Test fun `passwordReset unknown error`() = runTest { - val viewModel = RestorePasswordViewModel(interactor, resourceManager, analytics, appUpgradeNotifier) + val viewModel = + RestorePasswordViewModel(interactor, resourceManager, analytics, appUpgradeNotifier) coEvery { interactor.passwordReset(correctEmail) } throws Exception() - every { analytics.resetPasswordClickedEvent(false) } returns Unit + every { analytics.logEvent(any(), any()) } returns Unit viewModel.passwordReset(correctEmail) advanceUntilIdle() coVerify(exactly = 1) { interactor.passwordReset(any()) } - verify(exactly = 1) { analytics.resetPasswordClickedEvent(false) } + verify(exactly = 2) { analytics.logEvent(any(), any()) } verify(exactly = 1) { appUpgradeNotifier.notifier } val message = viewModel.uiMessage.value as? UIMessage.SnackBarMessage @@ -151,13 +160,14 @@ class RestorePasswordViewModelTest { @Test fun `unSuccess restore password`() = runTest { - val viewModel = RestorePasswordViewModel(interactor, resourceManager, analytics, appUpgradeNotifier) + val viewModel = + RestorePasswordViewModel(interactor, resourceManager, analytics, appUpgradeNotifier) coEvery { interactor.passwordReset(correctEmail) } returns false - every { analytics.resetPasswordClickedEvent(false) } returns Unit + every { analytics.logEvent(any(), any()) } returns Unit viewModel.passwordReset(correctEmail) advanceUntilIdle() coVerify(exactly = 1) { interactor.passwordReset(any()) } - verify(exactly = 1) { analytics.resetPasswordClickedEvent(false) } + verify(exactly = 2) { analytics.logEvent(any(), any()) } verify(exactly = 1) { appUpgradeNotifier.notifier } val message = viewModel.uiMessage.value as? UIMessage.SnackBarMessage @@ -169,13 +179,14 @@ class RestorePasswordViewModelTest { @Test fun `success restore password`() = runTest { - val viewModel = RestorePasswordViewModel(interactor, resourceManager, analytics, appUpgradeNotifier) + val viewModel = + RestorePasswordViewModel(interactor, resourceManager, analytics, appUpgradeNotifier) coEvery { interactor.passwordReset(correctEmail) } returns true - every { analytics.resetPasswordClickedEvent(true) } returns Unit + every { analytics.logEvent(any(), any()) } returns Unit viewModel.passwordReset(correctEmail) advanceUntilIdle() coVerify(exactly = 1) { interactor.passwordReset(any()) } - verify(exactly = 1) { analytics.resetPasswordClickedEvent(true) } + verify(exactly = 2) { analytics.logEvent(any(), any()) } verify(exactly = 1) { appUpgradeNotifier.notifier } val state = viewModel.uiState.value as? RestorePasswordUIState.Success @@ -185,6 +196,4 @@ class RestorePasswordViewModelTest { assertEquals(true, viewModel.uiState.value is RestorePasswordUIState.Success) assertEquals(null, message) } - - -} \ No newline at end of file +} 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 98278be4d..b36aabb10 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 @@ -97,6 +97,7 @@ class SignInViewModelTest { every { validator.isEmailOrUserNameValid(any()) } returns false every { preferencesManager.user } returns user every { analytics.setUserIdForSession(any()) } returns Unit + every { analytics.logEvent(any(), any()) } returns Unit val viewModel = SignInViewModel( interactor = interactor, resourceManager = resourceManager, @@ -115,6 +116,7 @@ class SignInViewModelTest { viewModel.login("", "") coVerify(exactly = 0) { interactor.login(any(), any()) } verify(exactly = 0) { analytics.setUserIdForSession(any()) } + verify(exactly = 1) { analytics.logEvent(any(), any()) } val message = viewModel.uiMessage.value as UIMessage.SnackBarMessage val uiState = viewModel.uiState.value @@ -128,6 +130,7 @@ class SignInViewModelTest { every { validator.isEmailOrUserNameValid(any()) } returns false every { preferencesManager.user } returns user every { analytics.setUserIdForSession(any()) } returns Unit + every { analytics.logEvent(any(), any()) } returns Unit val viewModel = SignInViewModel( interactor = interactor, resourceManager = resourceManager, @@ -160,6 +163,7 @@ class SignInViewModelTest { every { validator.isPasswordValid(any()) } returns false every { preferencesManager.user } returns user every { analytics.setUserIdForSession(any()) } returns Unit + every { analytics.logEvent(any(), any()) } returns Unit coVerify(exactly = 0) { interactor.login(any(), any()) } val viewModel = SignInViewModel( interactor = interactor, @@ -193,6 +197,7 @@ class SignInViewModelTest { every { validator.isPasswordValid(any()) } returns false every { preferencesManager.user } returns user every { analytics.setUserIdForSession(any()) } returns Unit + every { analytics.logEvent(any(), any()) } returns Unit val viewModel = SignInViewModel( interactor = interactor, resourceManager = resourceManager, @@ -212,6 +217,7 @@ class SignInViewModelTest { coVerify(exactly = 0) { interactor.login(any(), any()) } verify(exactly = 0) { analytics.setUserIdForSession(any()) } + verify(exactly = 1) { analytics.logEvent(any(), any()) } val message = viewModel.uiMessage.value as UIMessage.SnackBarMessage val uiState = viewModel.uiState.value @@ -224,9 +230,9 @@ class SignInViewModelTest { fun `login success`() = runTest { 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 + every { analytics.logEvent(any(), any()) } returns Unit val viewModel = SignInViewModel( interactor = interactor, resourceManager = resourceManager, @@ -247,8 +253,8 @@ class SignInViewModelTest { advanceUntilIdle() coVerify(exactly = 1) { interactor.login(any(), any()) } - verify(exactly = 1) { analytics.userLoginEvent(any()) } verify(exactly = 1) { analytics.setUserIdForSession(any()) } + verify(exactly = 2) { analytics.logEvent(any(), any()) } verify(exactly = 1) { appUpgradeNotifier.notifier } val uiState = viewModel.uiState.value assertFalse(uiState.showProgress) @@ -262,6 +268,7 @@ class SignInViewModelTest { every { validator.isPasswordValid(any()) } returns true every { preferencesManager.user } returns user every { analytics.setUserIdForSession(any()) } returns Unit + every { analytics.logEvent(any(), any()) } returns Unit val viewModel = SignInViewModel( interactor = interactor, resourceManager = resourceManager, @@ -283,6 +290,7 @@ class SignInViewModelTest { coVerify(exactly = 1) { interactor.login(any(), any()) } verify(exactly = 0) { analytics.setUserIdForSession(any()) } + verify(exactly = 1) { analytics.logEvent(any(), any()) } verify(exactly = 1) { appUpgradeNotifier.notifier } val message = viewModel.uiMessage.value as? UIMessage.SnackBarMessage @@ -298,6 +306,7 @@ class SignInViewModelTest { every { validator.isPasswordValid(any()) } returns true every { preferencesManager.user } returns user every { analytics.setUserIdForSession(any()) } returns Unit + every { analytics.logEvent(any(), any()) } returns Unit val viewModel = SignInViewModel( interactor = interactor, resourceManager = resourceManager, @@ -320,6 +329,7 @@ class SignInViewModelTest { coVerify(exactly = 1) { interactor.login(any(), any()) } verify(exactly = 0) { analytics.setUserIdForSession(any()) } verify(exactly = 1) { appUpgradeNotifier.notifier } + verify(exactly = 1) { analytics.logEvent(any(), any()) } val message = viewModel.uiMessage.value as UIMessage.SnackBarMessage val uiState = viewModel.uiState.value @@ -334,6 +344,7 @@ class SignInViewModelTest { every { validator.isPasswordValid(any()) } returns true every { preferencesManager.user } returns user every { analytics.setUserIdForSession(any()) } returns Unit + every { analytics.logEvent(any(), any()) } returns Unit val viewModel = SignInViewModel( interactor = interactor, resourceManager = resourceManager, @@ -356,6 +367,7 @@ class SignInViewModelTest { coVerify(exactly = 1) { interactor.login(any(), any()) } verify(exactly = 0) { analytics.setUserIdForSession(any()) } verify(exactly = 1) { appUpgradeNotifier.notifier } + verify(exactly = 1) { analytics.logEvent(any(), any()) } val message = viewModel.uiMessage.value as UIMessage.SnackBarMessage val uiState = viewModel.uiState.value diff --git a/auth/src/test/java/org/openedx/auth/presentation/signup/SignUpViewModelTest.kt b/auth/src/test/java/org/openedx/auth/presentation/signup/SignUpViewModelTest.kt index 448889b5b..f304f7363 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 @@ -145,7 +145,7 @@ class SignUpViewModelTest { parametersMap ) coEvery { interactor.getRegistrationFields() } returns listOfFields - every { analytics.createAccountClickedEvent(any()) } returns Unit + every { analytics.logEvent(any(), any()) } returns Unit coEvery { interactor.register(parametersMap) } returns Unit coEvery { interactor.login("", "") } returns Unit every { preferencesManager.user } returns user @@ -158,7 +158,7 @@ class SignUpViewModelTest { viewModel.register() advanceUntilIdle() coVerify(exactly = 1) { interactor.validateRegistrationFields(any()) } - verify(exactly = 1) { analytics.createAccountClickedEvent(any()) } + verify(exactly = 1) { analytics.logEvent(any(), any()) } coVerify(exactly = 0) { interactor.register(any()) } coVerify(exactly = 0) { interactor.login(any(), any()) } verify(exactly = 0) { analytics.setUserIdForSession(any()) } @@ -195,7 +195,7 @@ class SignUpViewModelTest { parametersMap.getValue(ApiConstants.PASSWORD) ) } returns Unit - every { analytics.createAccountClickedEvent(any()) } returns Unit + every { analytics.logEvent(any(), any()) } returns Unit every { preferencesManager.user } returns user every { analytics.setUserIdForSession(any()) } returns Unit viewModel.getRegistrationFields() @@ -205,7 +205,7 @@ class SignUpViewModelTest { } viewModel.register() advanceUntilIdle() - verify(exactly = 1) { analytics.createAccountClickedEvent(any()) } + verify(exactly = 1) { analytics.logEvent(any(), any()) } verify(exactly = 0) { analytics.setUserIdForSession(any()) } coVerify(exactly = 1) { interactor.validateRegistrationFields(any()) } coVerify(exactly = 0) { interactor.register(any()) } @@ -238,13 +238,13 @@ class SignUpViewModelTest { coEvery { interactor.validateRegistrationFields(parametersMap) } throws Exception() coEvery { interactor.register(parametersMap) } returns Unit coEvery { interactor.login("", "") } returns Unit - every { analytics.createAccountClickedEvent(any()) } returns Unit + every { analytics.logEvent(any(), any()) } returns Unit every { preferencesManager.user } returns user every { analytics.setUserIdForSession(any()) } returns Unit viewModel.register() advanceUntilIdle() verify(exactly = 0) { analytics.setUserIdForSession(any()) } - verify(exactly = 1) { analytics.createAccountClickedEvent(any()) } + verify(exactly = 1) { analytics.logEvent(any(), any()) } coVerify(exactly = 1) { interactor.validateRegistrationFields(any()) } coVerify(exactly = 0) { interactor.register(any()) } coVerify(exactly = 0) { interactor.login(any(), any()) } @@ -274,8 +274,8 @@ class SignUpViewModelTest { coEvery { interactor.validateRegistrationFields(parametersMap) } returns ValidationFields( emptyMap() ) - every { analytics.createAccountClickedEvent(any()) } returns Unit - every { analytics.registrationSuccessEvent(any()) } returns Unit + every { analytics.logEvent(any(), any()) } returns Unit + coEvery { analytics.logEvent(any(), any()) } returns Unit coEvery { interactor.getRegistrationFields() } returns listOfFields coEvery { interactor.register(parametersMap) } returns Unit coEvery { @@ -297,8 +297,7 @@ class SignUpViewModelTest { coVerify(exactly = 1) { interactor.validateRegistrationFields(any()) } coVerify(exactly = 1) { interactor.register(any()) } coVerify(exactly = 1) { interactor.login(any(), any()) } - verify(exactly = 1) { analytics.createAccountClickedEvent(any()) } - verify(exactly = 1) { analytics.registrationSuccessEvent(any()) } + verify(exactly = 2) { analytics.logEvent(any(), any()) } verify(exactly = 1) { appUpgradeNotifier.notifier } assertFalse(viewModel.uiState.value.validationError) 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 ec6391fe4..4f411551e 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 @@ -5,7 +5,7 @@ import org.openedx.core.R data class VideoSettings( val wifiDownloadOnly: Boolean, val videoStreamingQuality: VideoQuality, - val videoDownloadQuality: VideoQuality + val videoDownloadQuality: VideoQuality, ) { companion object { val default = VideoSettings(true, VideoQuality.AUTO, VideoQuality.AUTO) @@ -16,30 +16,35 @@ enum class VideoQuality( val titleResId: Int, val desResId: Int = 0, val width: Int, - val height: Int + val height: Int, + val tagId: String = "", // for analytics ) { AUTO( titleResId = R.string.core_video_quality_auto, desResId = R.string.core_video_quality_auto_description, width = 0, - height = 0 + height = 0, + tagId = "auto", ), OPTION_360P( titleResId = R.string.core_video_quality_p360, desResId = R.string.core_video_quality_p360_description, width = 640, - height = 360 + height = 360, + tagId = "low", ), OPTION_540P( titleResId = R.string.core_video_quality_p540, desResId = 0, width = 960, - height = 540 + height = 540, + tagId = "medium", ), OPTION_720P( titleResId = R.string.core_video_quality_p720, desResId = R.string.core_video_quality_p720_description, width = 1280, - height = 720 + height = 720, + tagId = "high", ); } diff --git a/core/src/main/java/org/openedx/core/module/download/BaseDownloadViewModel.kt b/core/src/main/java/org/openedx/core/module/download/BaseDownloadViewModel.kt index c96c986a2..b6e8a33dc 100644 --- a/core/src/main/java/org/openedx/core/module/download/BaseDownloadViewModel.kt +++ b/core/src/main/java/org/openedx/core/module/download/BaseDownloadViewModel.kt @@ -15,13 +15,18 @@ import org.openedx.core.module.DownloadWorkerController import org.openedx.core.module.db.DownloadDao import org.openedx.core.module.db.DownloadModel import org.openedx.core.module.db.DownloadedState +import org.openedx.core.presentation.CoreAnalytics +import org.openedx.core.presentation.CoreAnalyticsEvent +import org.openedx.core.presentation.CoreAnalyticsKey import org.openedx.core.utils.Sha1Util import java.io.File abstract class BaseDownloadViewModel( + private val courseId: String, private val downloadDao: DownloadDao, private val preferencesManager: CorePreferences, - private val workerController: DownloadWorkerController + private val workerController: DownloadWorkerController, + private val analytics: CoreAnalytics, ) : BaseViewModel() { private val allBlocks = hashMapOf() @@ -106,6 +111,7 @@ abstract class BaseDownloadViewModel( open fun saveDownloadModels(folder: String, id: String) { viewModelScope.launch { val saveBlocksIds = downloadableChildrenMap[id] ?: listOf() + logSubsectionDownloadEvent(id, saveBlocksIds.size) saveDownloadModels(folder, saveBlocksIds) } } @@ -196,6 +202,7 @@ abstract class BaseDownloadViewModel( open fun removeDownloadModels(blockId: String) { viewModelScope.launch { val downloadableChildren = downloadableChildrenMap[blockId] ?: listOf() + logSubsectionDeleteEvent(blockId, downloadableChildren.size) workerController.removeModels(downloadableChildren) } } @@ -235,4 +242,45 @@ abstract class BaseDownloadViewModel( } } + fun logBulkDownloadToggleEvent(toggle: Boolean) { + logEvent( + CoreAnalyticsEvent.VIDEO_BULK_DOWNLOAD_TOGGLE, + buildMap { + put( + CoreAnalyticsKey.ACTION.key, + if (toggle) CoreAnalyticsKey.TRUE.key else CoreAnalyticsKey.FALSE.key + ) + } + ) + } + + private fun logSubsectionDownloadEvent(subsectionId: String, numberOfVideos: Int) { + logEvent( + CoreAnalyticsEvent.VIDEO_DOWNLOAD_SUBSECTION, + buildMap { + put(CoreAnalyticsKey.BLOCK_ID.key, subsectionId) + put(CoreAnalyticsKey.NUMBER_OF_VIDEOS.key, numberOfVideos) + }) + } + + private fun logSubsectionDeleteEvent(subsectionId: String, numberOfVideos: Int) { + logEvent( + CoreAnalyticsEvent.VIDEO_DELETE_SUBSECTION, + buildMap { + put(CoreAnalyticsKey.BLOCK_ID.key, subsectionId) + put(CoreAnalyticsKey.NUMBER_OF_VIDEOS.key, numberOfVideos) + }) + } + + private fun logEvent(event: CoreAnalyticsEvent, param: Map = emptyMap()) { + analytics.logEvent( + event.eventName, + buildMap { + put(CoreAnalyticsKey.NAME.key, event.biValue) + put(CoreAnalyticsKey.CATEGORY.key, CoreAnalyticsKey.VIDEOS.key) + put(CoreAnalyticsKey.COURSE_ID.key, courseId) + putAll(param) + } + ) + } } diff --git a/core/src/main/java/org/openedx/core/presentation/CoreAnalytics.kt b/core/src/main/java/org/openedx/core/presentation/CoreAnalytics.kt new file mode 100644 index 000000000..49946903a --- /dev/null +++ b/core/src/main/java/org/openedx/core/presentation/CoreAnalytics.kt @@ -0,0 +1,63 @@ +package org.openedx.core.presentation + +interface CoreAnalytics { + fun logEvent(event: String, params: Map) +} + +enum class CoreAnalyticsEvent(val eventName: String, val biValue: String) { + EXTERNAL_LINK_OPENING_ALERT( + "External:Link Opening Alert", + "edx.bi.app.discovery.external_link.opening.alert" + ), + EXTERNAL_LINK_OPENING_ALERT_ACTION( + "External:Link Opening Alert Action", + "edx.bi.app.discovery.external_link.opening.alert_action" + ), + VIDEO_BULK_DOWNLOAD_TOGGLE( + "Video:Bulk Download Toggle", + "edx.bi.app.videos.download.toggle" + ), + VIDEO_DOWNLOAD_SUBSECTION( + "Video:Download Subsection", + "edx.bi.video.download.subsection" + ), + VIDEO_DELETE_SUBSECTION( + "Videos:Delete Subsection", + "edx.bi.app.video.delete.subsection" + ), + VIDEO_STREAMING_QUALITY_CHANGED( + "Video:Streaming Quality Changed", + "edx.bi.app.video.streaming_quality.changed" + ), + VIDEO_DOWNLOAD_QUALITY_CHANGED( + "Video:Download Quality Changed", + "edx.bi.app.video.download_quality.changed" + ), +} + +enum class CoreAnalyticsKey(val key: String) { + NAME("name"), + CATEGORY("category"), + DISCOVERY("discovery"), + VIDEOS("videos"), + PROFILE("profile"), + URL("url"), + ACTION("action"), + CANCEL("cancel"), + CONTINUE("continue"), + SCREEN_NAME("screen_name"), + VALUE("value"), + OLD_VALUE("old_value"), + COURSE_ID("course_id"), + BLOCK_ID("block_id"), + TRUE("true"), + FALSE("false"), + NUMBER_OF_VIDEOS("number_of_videos"), +} + +enum class CoreAnalyticsScreen(val screenName: String) { + DISCOVERY("Discovery"), + PROGRAM("Program"), + COURSE_INFO("Course Info"), + COURSE_DATES("Course Dates"), +} 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 index e502a136c..b7b3167e6 100644 --- 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 @@ -31,6 +31,9 @@ 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.CoreAnalytics +import org.openedx.core.presentation.CoreAnalyticsEvent +import org.openedx.core.presentation.CoreAnalyticsKey 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 @@ -42,6 +45,9 @@ import org.openedx.core.utils.UrlUtils class ActionDialogFragment : DialogFragment() { private val config by inject() + private val analytics: CoreAnalytics by inject() + private lateinit var url: String + private lateinit var screen: String override fun onCreateView( inflater: LayoutInflater, @@ -53,40 +59,71 @@ class ActionDialogFragment : DialogFragment() { setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed) setContent { OpenEdXTheme { + url = requireArguments().getString(ARG_URL, "") + screen = requireArguments().getString(ARG_SCREEN, "") ActionDialog( title = requireArguments().getString(ARG_TITLE, ""), message = requireArguments().getString(ARG_MESSAGE, ""), onPositiveClick = { + logDialogActionEvent(CoreAnalyticsKey.CANCEL.key) dismiss() }, onNegativeClick = { UrlUtils.openInBrowser( activity = context, apiHostUrl = config.getApiHostURL(), - url = requireArguments().getString(ARG_URL, ""), + url = url, ) + logDialogActionEvent(CoreAnalyticsKey.CONTINUE.key) dismiss() } ) + logDialogEvent(event = CoreAnalyticsEvent.EXTERNAL_LINK_OPENING_ALERT) } } } + private fun logDialogActionEvent(action: String) { + logDialogEvent( + event = CoreAnalyticsEvent.EXTERNAL_LINK_OPENING_ALERT_ACTION, + action = action + ) + } + + private fun logDialogEvent( + event: CoreAnalyticsEvent, + action: String? = null, + ) { + analytics.logEvent( + event = event.eventName, + params = buildMap { + put(CoreAnalyticsKey.NAME.key, event.biValue) + put(CoreAnalyticsKey.CATEGORY.key, CoreAnalyticsKey.DISCOVERY.key) + put(CoreAnalyticsKey.URL.key, url) + put(CoreAnalyticsKey.SCREEN_NAME.key, screen) + action?.let { put(CoreAnalyticsKey.ACTION.key, action) } + } + ) + } + companion object { private const val ARG_TITLE = "title" private const val ARG_MESSAGE = "message" private const val ARG_URL = "url" + private const val ARG_SCREEN = "screen" fun newInstance( title: String, message: String, url: String, + source: String, ): ActionDialogFragment { val fragment = ActionDialogFragment() fragment.arguments = bundleOf( ARG_TITLE to title, ARG_MESSAGE to message, ARG_URL to url, + ARG_SCREEN to source ) return fragment } diff --git a/core/src/main/java/org/openedx/core/presentation/dialog/appreview/AppReviewAnalytics.kt b/core/src/main/java/org/openedx/core/presentation/dialog/appreview/AppReviewAnalytics.kt new file mode 100644 index 000000000..975c62ce9 --- /dev/null +++ b/core/src/main/java/org/openedx/core/presentation/dialog/appreview/AppReviewAnalytics.kt @@ -0,0 +1,29 @@ +package org.openedx.core.presentation.dialog.appreview + +interface AppReviewAnalytics { + fun logEvent(event: String, params: Map) +} + +enum class AppReviewAnalyticsEvent(val eventName: String, val biValue: String) { + RATING_DIALOG( + "AppReviews:Rating Dialog Viewed", + "edx.bi.app.app_reviews.rating_dialog.viewed" + ), + RATING_DIALOG_ACTION( + "AppReviews:Rating Dialog Action", + "edx.bi.app.app_reviews.rating_dialog.action" + ), +} + +enum class AppReviewAnalyticsKey(val key: String) { + NAME("name"), + CATEGORY("category"), + RATING("rating"), + APP_REVIEW("app_review"), + ACTION("action"), + DISMISSED("dismissed"), + NOT_NOW("not_now"), + SUBMIT("submit"), + SHARE_FEEDBACK("share_feedback"), + RATE_APP("rate_app"), +} 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 index 427c71959..dac71cf77 100644 --- 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 @@ -3,12 +3,14 @@ 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.extension.nonZero import org.openedx.core.presentation.global.AppData open class BaseAppReviewDialogFragment : DialogFragment() { private val reviewPreferences: InAppReviewPreferences by inject() protected val appData: AppData by inject() + protected val analytics: AppReviewAnalytics by inject() fun saveVersionName() { val versionName = appData.versionName @@ -19,8 +21,54 @@ open class BaseAppReviewDialogFragment : DialogFragment() { reviewPreferences.wasPositiveRated = true } - fun notNowClick() { + fun onRatingDialogShowed() { + analytics.logEvent( + event = AppReviewAnalyticsEvent.RATING_DIALOG.eventName, + params = buildMap { + put(AppReviewAnalyticsKey.NAME.key, AppReviewAnalyticsEvent.RATING_DIALOG.biValue) + put(AppReviewAnalyticsKey.CATEGORY.key, AppReviewAnalyticsKey.APP_REVIEW.key) + } + ) + } + + fun notNowClick(rating: Int = 0) { saveVersionName() - dismiss() + logDialogActionEvent(AppReviewAnalyticsKey.NOT_NOW.key, rating) + super.dismiss() + } + + fun onSubmitRatingClick(rating: Int) { + logDialogActionEvent(AppReviewAnalyticsKey.SUBMIT.key, rating) + super.dismiss() + } + + fun onShareFeedbackClick() { + logDialogActionEvent(AppReviewAnalyticsKey.SHARE_FEEDBACK.key) + super.dismiss() + } + + fun onRateAppClick() { + logDialogActionEvent(AppReviewAnalyticsKey.RATE_APP.key) + super.dismiss() + } + + fun onDismiss() { + logDialogActionEvent(AppReviewAnalyticsKey.DISMISSED.key) + super.dismiss() + } + + private fun logDialogActionEvent(action: String, rating: Int = 0) { + analytics.logEvent( + event = AppReviewAnalyticsEvent.RATING_DIALOG_ACTION.eventName, + params = buildMap { + put( + AppReviewAnalyticsKey.NAME.key, + AppReviewAnalyticsEvent.RATING_DIALOG_ACTION.biValue + ) + put(AppReviewAnalyticsKey.CATEGORY.key, AppReviewAnalyticsKey.APP_REVIEW.key) + put(AppReviewAnalyticsKey.ACTION.key, action) + rating.nonZero()?.let { put(AppReviewAnalyticsKey.RATING.key, it) } + } + ) } -} \ 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 index f882cbe8a..03d449c5f 100644 --- 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 @@ -33,7 +33,7 @@ class FeedbackDialogFragment : BaseAppReviewDialogFragment() { val feedback = rememberSaveable { mutableStateOf("") } FeedbackDialog( feedback = feedback, - onNotNowClick = this@FeedbackDialogFragment::notNowClick, + onNotNowClick = { this@FeedbackDialogFragment.notNowClick() }, onShareClick = { onShareClick(feedback.value) } @@ -51,6 +51,7 @@ class FeedbackDialogFragment : BaseAppReviewDialogFragment() { } private fun onShareClick(feedback: String) { + onShareFeedbackClick() saveVersionName() wasShareClicked = true sendEmail(feedback) @@ -75,6 +76,11 @@ class FeedbackDialogFragment : BaseAppReviewDialogFragment() { ) } + + override fun dismiss() { + onDismiss() + } + companion object { fun newInstance(): FeedbackDialogFragment { return FeedbackDialogFragment() 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 index 1cfa034b9..c8f49153c 100644 --- 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 @@ -11,7 +11,7 @@ import androidx.compose.ui.platform.ComposeView import androidx.compose.ui.platform.ViewCompositionStrategy import org.openedx.core.ui.theme.OpenEdXTheme -class RateDialogFragment: BaseAppReviewDialogFragment() { +class RateDialogFragment : BaseAppReviewDialogFragment() { override fun onCreateView( inflater: LayoutInflater, container: ViewGroup?, @@ -26,17 +26,16 @@ class RateDialogFragment: BaseAppReviewDialogFragment() { val rating = rememberSaveable { mutableIntStateOf(0) } RateDialog( rating = rating, - onNotNowClick = this@RateDialogFragment::notNowClick, - onSubmitClick = { - onSubmitClick(rating.intValue) - } + onNotNowClick = { notNowClick(rating.intValue) }, + onSubmitClick = { onSubmitClick(rating.intValue) } ) } } + onRatingDialogShowed() } private fun onSubmitClick(rating: Int) { - dismiss() + onSubmitRatingClick(rating) if (rating > 3) { openThankYouDialog() } else { @@ -62,6 +61,10 @@ class RateDialogFragment: BaseAppReviewDialogFragment() { ) } + override fun dismiss() { + onDismiss() + } + companion object { fun newInstance(): RateDialogFragment { return RateDialogFragment() 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 index 89fe98c1c..137672f45 100644 --- 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 @@ -47,7 +47,9 @@ class ThankYouDialogFragment : BaseAppReviewDialogFragment() { ThankYouDialog( description = description, showButtons = isFeedbackPositive.value, - onNotNowClick = this@ThankYouDialogFragment::notNowClick, + onNotNowClick = { + this@ThankYouDialogFragment.notNowClick() + }, onRateUsClick = this@ThankYouDialogFragment::openInAppReview ) @@ -75,7 +77,7 @@ class ThankYouDialogFragment : BaseAppReviewDialogFragment() { flow.addOnCompleteListener { _ -> onPositiveRate() } - dismiss() + onRateAppClick() } } catch (e: ReviewException) { e.printStackTrace() @@ -83,6 +85,10 @@ class ThankYouDialogFragment : BaseAppReviewDialogFragment() { } } + override fun dismiss() { + onDismiss() + } + companion object { private const val ARG_IS_FEEDBACK_POSITIVE = "is_feedback_positive" @@ -97,4 +103,4 @@ class ThankYouDialogFragment : BaseAppReviewDialogFragment() { return fragment } } -} \ No newline at end of file +} diff --git a/core/src/main/java/org/openedx/core/presentation/settings/VideoQualityViewModel.kt b/core/src/main/java/org/openedx/core/presentation/settings/VideoQualityViewModel.kt index 02f00851c..c6d5176ea 100644 --- a/core/src/main/java/org/openedx/core/presentation/settings/VideoQualityViewModel.kt +++ b/core/src/main/java/org/openedx/core/presentation/settings/VideoQualityViewModel.kt @@ -7,13 +7,17 @@ import kotlinx.coroutines.launch import org.openedx.core.BaseViewModel import org.openedx.core.data.storage.CorePreferences import org.openedx.core.domain.model.VideoQuality +import org.openedx.core.presentation.CoreAnalytics +import org.openedx.core.presentation.CoreAnalyticsEvent +import org.openedx.core.presentation.CoreAnalyticsKey import org.openedx.core.system.notifier.VideoNotifier import org.openedx.core.system.notifier.VideoQualityChanged class VideoQualityViewModel( + private val qualityType: String, private val preferencesManager: CorePreferences, private val notifier: VideoNotifier, - private val qualityType: String + private val analytics: CoreAnalytics, ) : BaseViewModel() { private val _videoQuality = MutableLiveData() @@ -32,6 +36,7 @@ class VideoQualityViewModel( fun setVideoQuality(quality: VideoQuality) { val currentSettings = preferencesManager.videoSettings + logVideoQualityChangedEvent(getCurrentVideoQuality(), quality) if (getQualityType() == VideoQualityType.Streaming) { preferencesManager.videoSettings = currentSettings.copy(videoStreamingQuality = quality) } else { @@ -44,4 +49,22 @@ class VideoQualityViewModel( } fun getQualityType() = VideoQualityType.valueOf(qualityType) + + private fun logVideoQualityChangedEvent(oldQuality: VideoQuality, newQuality: VideoQuality) { + val event = + if (getQualityType() == VideoQualityType.Streaming) + CoreAnalyticsEvent.VIDEO_STREAMING_QUALITY_CHANGED + else + CoreAnalyticsEvent.VIDEO_DOWNLOAD_QUALITY_CHANGED + + analytics.logEvent( + event.eventName, + mapOf( + CoreAnalyticsKey.NAME.key to event.biValue, + CoreAnalyticsKey.CATEGORY.key to CoreAnalyticsKey.PROFILE.key, + CoreAnalyticsKey.VALUE.key to newQuality.tagId, + CoreAnalyticsKey.OLD_VALUE.key to oldQuality.tagId, + ) + ) + } } 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 17ac1524b..0ba05a5e4 100644 --- a/core/src/main/java/org/openedx/core/ui/ComposeCommon.kt +++ b/core/src/main/java/org/openedx/core/ui/ComposeCommon.kt @@ -149,7 +149,7 @@ fun Toolbar( modifier: Modifier = Modifier, label: String, canShowBackBtn: Boolean = false, - onBackClick: () -> Unit = {} + onBackClick: () -> Unit = {}, ) { Box( modifier = modifier @@ -165,7 +165,7 @@ fun Toolbar( .fillMaxWidth() .testTag("txt_toolbar_title") .align(Alignment.Center) - .padding(start = 48.dp, end = 48.dp), + .padding(horizontal = 48.dp), text = label, color = MaterialTheme.appColors.textPrimary, style = MaterialTheme.appTypography.titleMedium, @@ -449,7 +449,8 @@ fun HyperlinkText( annotatedString .getStringAnnotations("URL", it, it) .firstOrNull()?.let { stringAnnotation -> - action?.invoke(stringAnnotation.item) ?: uriHandler.openUri(stringAnnotation.item) + action?.invoke(stringAnnotation.item) + ?: uriHandler.openUri(stringAnnotation.item) } } ) @@ -586,7 +587,7 @@ fun SheetContent( expandedList: List, onItemClick: (RegistrationField.Option) -> Unit, listState: LazyListState, - searchValueChanged: (String) -> Unit + searchValueChanged: (String) -> Unit, ) { val focusManager = LocalFocusManager.current Column( @@ -650,7 +651,7 @@ fun SheetContent( title: String = stringResource(id = R.string.core_select_value), expandedList: List>, onItemClick: (Pair) -> Unit, - searchValueChanged: (String) -> Unit + searchValueChanged: (String) -> Unit, ) { val focusManager = LocalFocusManager.current Column( @@ -827,7 +828,7 @@ fun DiscoveryCourseItem( apiHostUrl: String, course: Course, windowSize: WindowSize, - onClick: (String) -> Unit + onClick: (String) -> Unit, ) { val imageWidth by remember(key1 = windowSize) { @@ -1091,7 +1092,7 @@ fun OpenEdXButton( onClick: () -> Unit, enabled: Boolean = true, backgroundColor: Color = MaterialTheme.appColors.buttonBackground, - content: (@Composable RowScope.() -> Unit)? = null + content: (@Composable RowScope.() -> Unit)? = null, ) { Button( modifier = Modifier @@ -1126,7 +1127,7 @@ fun OpenEdXOutlinedButton( textColor: Color, text: String = "", onClick: () -> Unit, - content: (@Composable RowScope.() -> Unit)? = null + content: (@Composable RowScope.() -> Unit)? = null, ) { OutlinedButton( modifier = Modifier @@ -1155,7 +1156,7 @@ fun OpenEdXOutlinedButton( fun BackBtn( modifier: Modifier = Modifier, tint: Color = MaterialTheme.appColors.primary, - onBackClick: () -> Unit + onBackClick: () -> Unit, ) { IconButton(modifier = modifier.testTag("ib_back"), onClick = { onBackClick() }) { @@ -1170,7 +1171,7 @@ fun BackBtn( @Composable fun ConnectionErrorView( modifier: Modifier, - onReloadClick: () -> Unit + onReloadClick: () -> Unit, ) { Column( modifier = modifier, @@ -1212,7 +1213,7 @@ fun ConnectionErrorView( @Composable fun AuthButtonsPanel( onRegisterClick: () -> Unit, - onSignInClick: () -> Unit + onSignInClick: () -> Unit, ) { Row { OpenEdXButton( diff --git a/course/src/main/java/org/openedx/course/presentation/CourseAnalytics.kt b/course/src/main/java/org/openedx/course/presentation/CourseAnalytics.kt index cdae67678..ce0dafef5 100644 --- a/course/src/main/java/org/openedx/course/presentation/CourseAnalytics.kt +++ b/course/src/main/java/org/openedx/course/presentation/CourseAnalytics.kt @@ -1,20 +1,230 @@ package org.openedx.course.presentation interface CourseAnalytics { - fun courseEnrollClickedEvent(courseId: String, courseName: String) - fun courseEnrollSuccessEvent(courseId: String, courseName: String) - fun viewCourseClickedEvent(courseId: String, courseName: String) - fun resumeCourseTappedEvent(courseId: String, courseName: String, blockId: String) - fun sequentialClickedEvent(courseId: String, courseName: String, blockId: String, blockName: String) - fun verticalClickedEvent(courseId: String, courseName: String, blockId: String, blockName: String) - fun nextBlockClickedEvent(courseId: String, courseName: String, blockId: String, blockName: String) - fun prevBlockClickedEvent(courseId: String, courseName: String, blockId: String, blockName: String) - fun finishVerticalClickedEvent(courseId: String, courseName: String, blockId: String, blockName: String) - fun finishVerticalNextClickedEvent(courseId: String, courseName: String, blockId: String, blockName: String) + fun sequentialClickedEvent( + courseId: String, + courseName: String, + blockId: String, + blockName: String, + ) + + fun nextBlockClickedEvent( + courseId: String, + courseName: String, + blockId: String, + blockName: String, + ) + + fun prevBlockClickedEvent( + courseId: String, + courseName: String, + blockId: String, + blockName: String, + ) + + fun finishVerticalClickedEvent( + courseId: String, + courseName: String, + blockId: String, + blockName: String, + ) + + fun finishVerticalNextClickedEvent( + courseId: String, + courseName: String, + blockId: String, + blockName: String, + ) + fun finishVerticalBackClickedEvent(courseId: String, courseName: String) - fun courseTabClickedEvent(courseId: String, courseName: String) - fun videoTabClickedEvent(courseId: String, courseName: String) - fun discussionTabClickedEvent(courseId: String, courseName: String) - fun datesTabClickedEvent(courseId: String, courseName: String) - fun handoutsTabClickedEvent(courseId: String, courseName: String) + fun logEvent(event: String, params: Map) +} + +enum class CourseAnalyticsEvent(val eventName: String, val biValue: String) { + COURSE_ENROLL_CLICKED( + "Discovery:Course Enroll Clicked", + "edx.bi.app.course.enroll.clicked" + ), + COURSE_ENROLL_SUCCESS( + "Discovery:Course Enroll Success", + "edx.bi.app.course.enroll.success" + ), + COURSE_INFO( + "Discovery:Course Info", + "edx.bi.app.discovery.course_info" + ), + PROGRAM_INFO( + "Discovery:Program Info", + "edx.bi.app.discovery.program_info" + ), + DASHBOARD( + "Course:Dashboard", + "edx.bi.app.course.dashboard" + ), + HOME_TAB( + "Course:Home Tab", + "edx.bi.app.course.home_tab" + ), + VIDEOS_TAB( + "Course:Videos Tab", + "edx.bi.app.course.video_tab" + ), + DISCUSSION_TAB( + "Course:Discussion Tab", + "edx.bi.app.course.discussion_tab" + ), + DATES_TAB( + "Course:Dates Tab", + "edx.bi.app.course.dates_tab" + ), + HANDOUTS_TAB( + "Course:Handouts Tab", + "edx.bi.app.course.handouts_tab" + ), + ANNOUNCEMENTS( + "Course:Announcements", + "edx.bi.app.course.announcements" + ), + HANDOUTS( + "Course:Handouts", + "edx.bi.app.course.handouts" + ), + UNIT_DETAIL( + "Course:Unit Detail", + "edx.bi.app.course.unit_detail" + ), + VIEW_CERTIFICATE( + "Course:View Certificate Clicked", + "edx.bi.app.course.view_certificate.clicked" + ), + RESUME_COURSE_CLICKED( + "Course:Resume Course Clicked", + "edx.bi.app.course.resume_course.clicked" + ), + VIDEO_LOADED( + "Video:Loaded", + "edx.bi.app.videos.loaded" + ), + VIDEO_CHANGE_SPEED( + "Video:Change Speed", + "edx.bi.app.videos.speed.changed" + ), + VIDEO_PLAYED( + "Video:Played", + "edx.bi.app.videos.played" + ), + VIDEO_PAUSED( + "Video:Paused", + "edx.bi.app.videos.paused" + ), + VIDEO_SEEKED( + "Video:Seeked", + "edx.bi.app.videos.position.changed" + ), + VIDEO_COMPLETED( + "Video:Completed", + "edx.bi.app.videos.completed" + ), + CAST_CONNECTED( + "Cast:Connected", + "edx.bi.app.cast.connected" + ), + CAST_DISCONNECTED( + "Cast:Disconnected", + "edx.bi.app.cast.disconnected" + ), + DATES_COURSE_COMPONENT_CLICKED( + "Dates:Course Component Clicked", + "edx.bi.app.dates.component.clicked" + ), + PLS_BANNER_VIEWED( + "PLS:Banner Viewed", + "edx.bi.app.coursedates.pls_banner.viewed" + ), + PLS_SHIFT_BUTTON_CLICKED( + "PLS:Shift Button Clicked", + "edx.bi.app.dates.pls_banner.shift_dates.clicked" + ), + PLS_SHIFT_DATES( + "PLS:Shift Dates", + "edx.bi.app.coursedates.pls_banner.shift_dates" + ), + DATES_CALENDAR_SYNC_TOGGLE( + "Dates:CalendarSync Toggle", + "edx.bi.app.dates.calendar_sync.toggle" + ), + DATES_CALENDAR_SYNC_DIALOG_ACTION( + "Dates:CalendarSync Dialog Action", + "edx.bi.app.dates.calendar_sync.dialog_action" + ), + DATES_CALENDAR_SYNC_SNACKBAR( + "Dates:CalendarSync Snackbar", + "edx.bi.app.dates.calendar_sync.snackbar" + ), +} + +enum class CourseAnalyticsKey(val key: String) { + NAME("name"), + COURSE_ID("course_id"), + COURSE_NAME("course_name"), + CONVERSION("conversion"), + OPEN_IN_BROWSER("open_in_browser_url"), + COMPONENT("component"), + VIDEO_PLAYER("video_player"), + ENROLLMENT_MODE("mode"), + PACING("pacing"), + SCREEN_NAME("screen_name"), + BANNER_TYPE("banner_type"), + CATEGORY("category"), + SUCCESS("success"), + LINK("link"), + SUPPORTED("supported"), + BLOCK_ID("block_id"), + BLOCK_NAME("block_name"), + BLOCK_TYPE("block_type"), + PLAY_MEDIUM("play_medium"), + NATIVE("native"), + YOUTUBE("youtube"), + GOOGLE_CAST("google_cast"), + CURRENT_TIME("current_time"), + SKIP_INTERVAL("requested_skip_interval"), + SPEED("speed"), + NAVIGATION("navigation"), + DIALOG("dialog"), + ACTION("action"), + ON("on"), + OFF("off"), + SNACKBAR("snackbar"), + COURSE_DATES("course_dates"), +} + +enum class CalendarSyncDialog( + val dialog: String, + private val positiveAction: String, + private val negativeAction: String, +) { + PERMISSION("permission", "allow", "donot_allow"), + ADD("add", "ok", "cancel"), + REMOVE("remove", "ok", "cancel"), + UPDATE("update", "update", "remove"), + CONFIRMED("confirmed", "view_event", "done"); + + fun getBuildMap(action: Boolean): Map { + return buildMap { + put(CourseAnalyticsKey.DIALOG.key, dialog) + put(CourseAnalyticsKey.ACTION.key, if (action) positiveAction else negativeAction) + } + } +} + +enum class CalendarSyncSnackbar(private val snackbar: String) { + ADD("add"), + REMOVE("remove"), + UPDATE("update"); + + fun getBuildMap(): Map { + return buildMap { + put(CourseAnalyticsKey.SNACKBAR.key, snackbar) + } + } } diff --git a/course/src/main/java/org/openedx/course/presentation/CourseRouter.kt b/course/src/main/java/org/openedx/course/presentation/CourseRouter.kt index fae658fde..a9bb8dfcf 100644 --- a/course/src/main/java/org/openedx/course/presentation/CourseRouter.kt +++ b/course/src/main/java/org/openedx/course/presentation/CourseRouter.kt @@ -8,7 +8,7 @@ import org.openedx.course.presentation.handouts.HandoutsType interface CourseRouter { fun navigateToCourseOutline( - fm: FragmentManager, courseId: String, courseTitle: String + fm: FragmentManager, courseId: String, courseTitle: String, enrollmentMode: String ) fun navigateToNoAccess( diff --git a/course/src/main/java/org/openedx/course/presentation/calendarsync/CalendarSyncDialog.kt b/course/src/main/java/org/openedx/course/presentation/calendarsync/CalendarSyncDialog.kt index 59be5999b..a3775c99b 100644 --- a/course/src/main/java/org/openedx/course/presentation/calendarsync/CalendarSyncDialog.kt +++ b/course/src/main/java/org/openedx/course/presentation/calendarsync/CalendarSyncDialog.kt @@ -37,21 +37,24 @@ import org.openedx.core.R as CoreR fun CalendarSyncDialog( syncDialogType: CalendarSyncDialogType, calendarTitle: String, - syncDialogAction: (CalendarSyncDialogType) -> Unit, - dismissSyncDialog: () -> Unit, + syncDialogPosAction: (CalendarSyncDialogType) -> Unit, + syncDialogNegAction: (CalendarSyncDialogType) -> Unit, + dismissSyncDialog: (CalendarSyncDialogType) -> Unit, ) { when (syncDialogType) { CalendarSyncDialogType.SYNC_DIALOG, - CalendarSyncDialogType.UN_SYNC_DIALOG -> { + CalendarSyncDialogType.UN_SYNC_DIALOG, + -> { CalendarAlertDialog( dialogProperties = DialogProperties( title = stringResource(syncDialogType.titleResId), message = stringResource(syncDialogType.messageResId, calendarTitle), positiveButton = stringResource(syncDialogType.positiveButtonResId), negativeButton = stringResource(syncDialogType.negativeButtonResId), - positiveAction = { syncDialogAction(syncDialogType) } + positiveAction = { syncDialogPosAction(syncDialogType) }, + negativeAction = { syncDialogNegAction(syncDialogType) }, ), - onDismiss = dismissSyncDialog, + onDismiss = { dismissSyncDialog(syncDialogType) }, ) } @@ -69,9 +72,10 @@ fun CalendarSyncDialog( ), positiveButton = stringResource(syncDialogType.positiveButtonResId), negativeButton = stringResource(syncDialogType.negativeButtonResId), - positiveAction = { syncDialogAction(syncDialogType) } + positiveAction = { syncDialogPosAction(syncDialogType) }, + negativeAction = { syncDialogNegAction(syncDialogType) }, ), - onDismiss = dismissSyncDialog + onDismiss = { dismissSyncDialog(syncDialogType) } ) } @@ -82,9 +86,10 @@ fun CalendarSyncDialog( message = stringResource(syncDialogType.messageResId, calendarTitle), positiveButton = stringResource(syncDialogType.positiveButtonResId), negativeButton = stringResource(syncDialogType.negativeButtonResId), - positiveAction = { syncDialogAction(syncDialogType) }, + positiveAction = { syncDialogPosAction(syncDialogType) }, + negativeAction = { syncDialogNegAction(syncDialogType) }, ), - onDismiss = dismissSyncDialog + onDismiss = { dismissSyncDialog(syncDialogType) } ) } @@ -95,10 +100,10 @@ fun CalendarSyncDialog( message = stringResource(syncDialogType.messageResId), positiveButton = stringResource(syncDialogType.positiveButtonResId), negativeButton = stringResource(syncDialogType.negativeButtonResId), - positiveAction = { syncDialogAction(syncDialogType) }, - negativeAction = { syncDialogAction(CalendarSyncDialogType.UN_SYNC_DIALOG) } + positiveAction = { syncDialogPosAction(syncDialogType) }, + negativeAction = { syncDialogNegAction(syncDialogType) }, ), - onDismiss = dismissSyncDialog + onDismiss = { dismissSyncDialog(syncDialogType) } ) } @@ -211,13 +216,14 @@ private fun SyncDialog() { @Preview(uiMode = Configuration.UI_MODE_NIGHT_NO) @Preview(uiMode = Configuration.UI_MODE_NIGHT_YES) private fun CalendarSyncDialogsPreview( - @PreviewParameter(CalendarSyncDialogTypeProvider::class) dialogType: CalendarSyncDialogType + @PreviewParameter(CalendarSyncDialogTypeProvider::class) dialogType: CalendarSyncDialogType, ) { OpenEdXTheme { CalendarSyncDialog( syncDialogType = dialogType, calendarTitle = "Hello to OpenEdx", - syncDialogAction = {}, + syncDialogPosAction = {}, + syncDialogNegAction = {}, dismissSyncDialog = {}, ) } diff --git a/course/src/main/java/org/openedx/course/presentation/container/CourseContainerFragment.kt b/course/src/main/java/org/openedx/course/presentation/container/CourseContainerFragment.kt index a5f22084b..ee4cea674 100644 --- a/course/src/main/java/org/openedx/course/presentation/container/CourseContainerFragment.kt +++ b/course/src/main/java/org/openedx/course/presentation/container/CourseContainerFragment.kt @@ -12,6 +12,7 @@ import androidx.core.view.isVisible import androidx.fragment.app.Fragment import androidx.viewpager2.widget.ViewPager2 import com.google.android.material.snackbar.Snackbar +import com.google.android.material.tabs.TabLayout import com.google.android.material.tabs.TabLayoutMediator import org.koin.android.ext.android.inject import org.koin.androidx.viewmodel.ext.android.viewModel @@ -39,7 +40,8 @@ class CourseContainerFragment : Fragment(R.layout.fragment_course_container) { private val viewModel by viewModel { parametersOf( requireArguments().getString(ARG_COURSE_ID, ""), - requireArguments().getString(ARG_TITLE, "") + requireArguments().getString(ARG_TITLE, ""), + requireArguments().getString(ARG_ENROLLMENT_MODE, "") ) } private val router by inject() @@ -49,6 +51,7 @@ class CourseContainerFragment : Fragment(R.layout.fragment_course_container) { private val permissionLauncher = registerForActivityResult( ActivityResultContracts.RequestMultiplePermissions() ) { isGranted -> + viewModel.logCalendarPermissionAccess(!isGranted.containsValue(false)) if (!isGranted.containsValue(false)) { viewModel.setCalendarSyncDialogType(CalendarSyncDialogType.SYNC_DIALOG) } @@ -132,7 +135,8 @@ class CourseContainerFragment : Fragment(R.layout.fragment_course_container) { CourseDatesFragment.newInstance( viewModel.courseId, viewModel.courseName, - viewModel.isSelfPaced + viewModel.isSelfPaced, + viewModel.enrollmentMode, ) ) addFragment( @@ -146,16 +150,26 @@ class CourseContainerFragment : Fragment(R.layout.fragment_course_container) { if (viewModel.isCourseTopTabBarEnabled) { TabLayoutMediator(binding.tabLayout, binding.viewPager) { tab, position -> tab.text = getString( - Tabs.values().find { it.ordinal == position }?.titleResId + Tabs.entries.find { it.ordinal == position }?.titleResId ?: R.string.course_navigation_course ) }.attach() binding.tabLayout.isVisible = true + binding.tabLayout.addOnTabSelectedListener(object : TabLayout.OnTabSelectedListener { + override fun onTabSelected(tab: TabLayout.Tab?) { + tab?.let { + viewModel.courseContainerTabClickedEvent(Tabs.entries[it.position]) + } + } + override fun onTabUnselected(p0: TabLayout.Tab?) {} + + override fun onTabReselected(p0: TabLayout.Tab?) {} + }) } else { binding.viewPager.isUserInputEnabled = false binding.bottomNavView.setOnItemSelectedListener { menuItem -> - Tabs.values().find { menuItem.itemId == it.itemId }?.let { tab -> + Tabs.entries.find { menuItem.itemId == it.itemId }?.let { tab -> viewModel.courseContainerTabClickedEvent(tab) binding.viewPager.setCurrentItem(tab.ordinal, false) } @@ -163,6 +177,7 @@ class CourseContainerFragment : Fragment(R.layout.fragment_course_container) { } binding.bottomNavView.isVisible = true } + viewModel.courseContainerTabClickedEvent(Tabs.entries[binding.viewPager.currentItem]) } private fun setUpCourseCalendar() { @@ -188,15 +203,17 @@ class CourseContainerFragment : Fragment(R.layout.fragment_course_container) { CalendarSyncDialog( syncDialogType = syncState.dialogType, calendarTitle = syncState.calendarTitle, - syncDialogAction = { dialog -> + syncDialogPosAction = { dialog -> when (dialog) { CalendarSyncDialogType.SYNC_DIALOG -> { + viewModel.logCalendarAddDates(true) viewModel.addOrUpdateEventsInCalendar( updatedEvent = false, ) } CalendarSyncDialogType.UN_SYNC_DIALOG -> { + viewModel.logCalendarRemoveDates(true) viewModel.deleteCourseCalendar() } @@ -205,19 +222,44 @@ class CourseContainerFragment : Fragment(R.layout.fragment_course_container) { } CalendarSyncDialogType.OUT_OF_SYNC_DIALOG -> { + viewModel.logCalendarSyncUpdate(true) viewModel.addOrUpdateEventsInCalendar( updatedEvent = true, ) } CalendarSyncDialogType.EVENTS_DIALOG -> { + viewModel.logCalendarSyncedConfirmation(true) viewModel.openCalendarApp() } + else -> {} + } + }, + syncDialogNegAction = { dialog -> + when (dialog) { + CalendarSyncDialogType.SYNC_DIALOG -> + viewModel.logCalendarAddDates(false) + + CalendarSyncDialogType.UN_SYNC_DIALOG -> + viewModel.logCalendarRemoveDates(false) + + CalendarSyncDialogType.OUT_OF_SYNC_DIALOG -> { + viewModel.logCalendarSyncUpdate(false) + viewModel.deleteCourseCalendar() + } + + CalendarSyncDialogType.EVENTS_DIALOG -> + viewModel.logCalendarSyncedConfirmation(false) + CalendarSyncDialogType.LOADING_DIALOG, - CalendarSyncDialogType.NONE -> { + CalendarSyncDialogType.PERMISSION_DIALOG, + CalendarSyncDialogType.NONE, + -> { } } + + viewModel.setCalendarSyncDialogType(CalendarSyncDialogType.NONE) }, dismissSyncDialog = { viewModel.setCalendarSyncDialogType(CalendarSyncDialogType.NONE) @@ -247,14 +289,17 @@ class CourseContainerFragment : Fragment(R.layout.fragment_course_container) { companion object { private const val ARG_COURSE_ID = "courseId" private const val ARG_TITLE = "title" + private const val ARG_ENROLLMENT_MODE = "enrollmentMode" fun newInstance( courseId: String, - courseTitle: String + courseTitle: String, + enrollmentMode: String, ): CourseContainerFragment { val fragment = CourseContainerFragment() fragment.arguments = bundleOf( ARG_COURSE_ID to courseId, - ARG_TITLE to courseTitle + ARG_TITLE to courseTitle, + ARG_ENROLLMENT_MODE to enrollmentMode ) return fragment } diff --git a/course/src/main/java/org/openedx/course/presentation/container/CourseContainerViewModel.kt b/course/src/main/java/org/openedx/course/presentation/container/CourseContainerViewModel.kt index 886c63319..cf78bb207 100644 --- a/course/src/main/java/org/openedx/course/presentation/container/CourseContainerViewModel.kt +++ b/course/src/main/java/org/openedx/course/presentation/container/CourseContainerViewModel.kt @@ -28,7 +28,11 @@ import org.openedx.core.utils.TimeUtils import org.openedx.course.R import org.openedx.course.data.storage.CoursePreferences import org.openedx.course.domain.interactor.CourseInteractor +import org.openedx.course.presentation.CalendarSyncDialog +import org.openedx.course.presentation.CalendarSyncSnackbar import org.openedx.course.presentation.CourseAnalytics +import org.openedx.course.presentation.CourseAnalyticsEvent +import org.openedx.course.presentation.CourseAnalyticsKey import org.openedx.course.presentation.calendarsync.CalendarManager import org.openedx.course.presentation.calendarsync.CalendarSyncDialogType import org.openedx.course.presentation.calendarsync.CalendarSyncUIState @@ -39,15 +43,16 @@ import org.openedx.core.R as CoreR class CourseContainerViewModel( val courseId: String, var courseName: String, + val enrollmentMode: String, private val config: Config, private val interactor: CourseInteractor, private val calendarManager: CalendarManager, private val resourceManager: ResourceManager, private val notifier: CourseNotifier, private val networkConnection: NetworkConnection, - private val analytics: CourseAnalytics, private val corePreferences: CorePreferences, private val coursePreferences: CoursePreferences, + private val courseAnalytics: CourseAnalytics, ) : BaseViewModel() { val isCourseTopTabBarEnabled get() = config.isCourseTopTabBarEnabled() @@ -106,6 +111,7 @@ class CourseContainerViewModel( } fun preloadCourseStructure() { + courseDashboardViewed() if (_dataReady.value != null) { return } @@ -212,8 +218,10 @@ class CourseContainerViewModel( updateCalendarSyncState() if (updatedEvent) { + logCalendarSyncSnackbar(CalendarSyncSnackbar.UPDATE) setUiMessage(R.string.course_snackbar_course_calendar_updated) } else if (coursePreferences.isCalendarSyncEventsDialogShown(courseName)) { + logCalendarSyncSnackbar(CalendarSyncSnackbar.ADD) setUiMessage(R.string.course_snackbar_course_calendar_added) } else { coursePreferences.setCalendarSyncEventsDialogShown(courseName) @@ -255,7 +263,9 @@ class CourseContainerViewModel( ) } updateCalendarSyncState() + } + logCalendarSyncSnackbar(CalendarSyncSnackbar.REMOVE) setUiMessage(R.string.course_snackbar_course_calendar_removed) } } @@ -282,23 +292,96 @@ class CourseContainerViewModel( (calendarSync.isInstructorPacedEnabled && !isSelfPaced)) } + private fun courseDashboardViewed() { + logCourseContainerEvent(CourseAnalyticsEvent.DASHBOARD) + } + private fun courseTabClickedEvent() { - analytics.courseTabClickedEvent(courseId, courseName) + logCourseContainerEvent(CourseAnalyticsEvent.HOME_TAB) } private fun videoTabClickedEvent() { - analytics.videoTabClickedEvent(courseId, courseName) + logCourseContainerEvent(CourseAnalyticsEvent.VIDEOS_TAB) } private fun discussionTabClickedEvent() { - analytics.discussionTabClickedEvent(courseId, courseName) + logCourseContainerEvent(CourseAnalyticsEvent.DISCUSSION_TAB) } private fun datesTabClickedEvent() { - analytics.datesTabClickedEvent(courseId, courseName) + logCourseContainerEvent(CourseAnalyticsEvent.DATES_TAB) } private fun handoutsTabClickedEvent() { - analytics.handoutsTabClickedEvent(courseId, courseName) + logCourseContainerEvent(CourseAnalyticsEvent.HANDOUTS_TAB) + } + + private fun logCourseContainerEvent(event: CourseAnalyticsEvent) { + courseAnalytics.logEvent( + event = event.eventName, + params = buildMap { + put(CourseAnalyticsKey.NAME.key, event.biValue) + put(CourseAnalyticsKey.COURSE_ID.key, courseId) + put(CourseAnalyticsKey.COURSE_NAME.key, courseName) + } + ) + } + + fun logCalendarPermissionAccess(isAllowed: Boolean) { + logCalendarSyncEvent( + CourseAnalyticsEvent.DATES_CALENDAR_SYNC_DIALOG_ACTION, + CalendarSyncDialog.PERMISSION.getBuildMap(isAllowed) + ) + } + + fun logCalendarAddDates(action: Boolean) { + logCalendarSyncEvent( + CourseAnalyticsEvent.DATES_CALENDAR_SYNC_DIALOG_ACTION, + CalendarSyncDialog.ADD.getBuildMap(action) + ) + } + + fun logCalendarRemoveDates(action: Boolean) { + logCalendarSyncEvent( + CourseAnalyticsEvent.DATES_CALENDAR_SYNC_DIALOG_ACTION, + CalendarSyncDialog.REMOVE.getBuildMap(action) + ) + } + + fun logCalendarSyncedConfirmation(action: Boolean) { + logCalendarSyncEvent( + CourseAnalyticsEvent.DATES_CALENDAR_SYNC_DIALOG_ACTION, + CalendarSyncDialog.CONFIRMED.getBuildMap(action) + ) + } + + fun logCalendarSyncUpdate(action: Boolean) { + logCalendarSyncEvent( + CourseAnalyticsEvent.DATES_CALENDAR_SYNC_DIALOG_ACTION, + CalendarSyncDialog.UPDATE.getBuildMap(action) + ) + } + + private fun logCalendarSyncSnackbar(snackbar: CalendarSyncSnackbar) { + logCalendarSyncEvent( + CourseAnalyticsEvent.DATES_CALENDAR_SYNC_SNACKBAR, + snackbar.getBuildMap() + ) + } + + private fun logCalendarSyncEvent( + event: CourseAnalyticsEvent, + param: Map = emptyMap(), + ) { + courseAnalytics.logEvent( + event = event.eventName, + params = buildMap { + put(CourseAnalyticsKey.NAME.key, event.biValue) + put(CourseAnalyticsKey.COURSE_ID.key, courseId) + put(CourseAnalyticsKey.ENROLLMENT_MODE.key, enrollmentMode) + put(CourseAnalyticsKey.PACING.key, isSelfPaced) + putAll(param) + } + ) } } diff --git a/course/src/main/java/org/openedx/course/presentation/dates/CourseDatesFragment.kt b/course/src/main/java/org/openedx/course/presentation/dates/CourseDatesFragment.kt index e906ef6a2..05a90b136 100644 --- a/course/src/main/java/org/openedx/course/presentation/dates/CourseDatesFragment.kt +++ b/course/src/main/java/org/openedx/course/presentation/dates/CourseDatesFragment.kt @@ -90,7 +90,9 @@ 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.extension.isNotEmptyThenLet +import org.openedx.core.presentation.CoreAnalyticsScreen import org.openedx.core.presentation.course.CourseViewMode +import org.openedx.core.presentation.dialog.alert.ActionDialogFragment import org.openedx.core.ui.HandleUIMessage import org.openedx.core.ui.OfflineModeDialog import org.openedx.core.ui.WindowSize @@ -117,11 +119,12 @@ import org.openedx.core.R as coreR class CourseDatesFragment : Fragment() { - private val viewModel by viewModel { + val viewModel by viewModel { parametersOf( requireArguments().getString(ARG_COURSE_ID, ""), requireArguments().getString(ARG_COURSE_NAME, ""), requireArguments().getBoolean(ARG_IS_SELF_PACED, true), + requireArguments().getString(ARG_ENROLLMENT_MODE, "") ) } private val router by inject() @@ -160,9 +163,10 @@ class CourseDatesFragment : Fragment() { onSwipeRefresh = { viewModel.getCourseDates(swipeToRefresh = true) }, - onItemClick = { blockId -> - if (blockId.isNotEmpty()) { - viewModel.getVerticalBlock(blockId)?.let { verticalBlock -> + onItemClick = { block -> + if (block.blockId.isNotEmpty()) { + viewModel.getVerticalBlock(block.blockId)?.let { verticalBlock -> + viewModel.logCourseComponentTapped(true, block) if (viewModel.isCourseExpandableSectionsEnabled) { router.navigateToCourseContainer( fm = requireActivity().supportFragmentManager, @@ -183,11 +187,33 @@ class CourseDatesFragment : Fragment() { ) } } + } ?: { + viewModel.logCourseComponentTapped(false, block) + 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 = block.link, + source = CoreAnalyticsScreen.COURSE_DATES.screenName + ).show( + requireActivity().supportFragmentManager, + ActionDialogFragment::class.simpleName + ) + } } }, + onPLSBannerViewed = { + if (isResumed) { + viewModel.logPlsBannerViewed() + } + }, onSyncDates = { + viewModel.logPlsShiftButtonClicked() viewModel.resetCourseDatesBanner { + viewModel.logPlsShiftDates(it) if (it) { (parentFragment as CourseContainerFragment) .updateCourseStructure(false) @@ -210,11 +236,13 @@ class CourseDatesFragment : Fragment() { private const val ARG_COURSE_ID = "courseId" private const val ARG_COURSE_NAME = "courseName" private const val ARG_IS_SELF_PACED = "selfPaced" + private const val ARG_ENROLLMENT_MODE = "enrollmentMode" fun newInstance( courseId: String, courseName: String, isSelfPaced: Boolean, + enrollmentMode: String, ): CourseDatesFragment { val fragment = CourseDatesFragment() fragment.arguments = @@ -222,6 +250,7 @@ class CourseDatesFragment : Fragment() { ARG_COURSE_ID to courseId, ARG_COURSE_NAME to courseName, ARG_IS_SELF_PACED to isSelfPaced, + ARG_ENROLLMENT_MODE to enrollmentMode, ) return fragment } @@ -240,7 +269,8 @@ internal fun CourseDatesScreen( calendarSyncUIState: CalendarSyncUIState, onReloadClick: () -> Unit, onSwipeRefresh: () -> Unit, - onItemClick: (String) -> Unit, + onItemClick: (CourseDateBlock) -> Unit, + onPLSBannerViewed: () -> Unit, onSyncDates: () -> Unit, onCalendarSyncSwitch: (Boolean) -> Unit = {}, ) { @@ -334,6 +364,7 @@ internal fun CourseDatesScreen( if (courseBanner.isBannerAvailableForUserType(isSelfPaced)) { item { + onPLSBannerViewed() if (windowSize.isTablet) { CourseDatesBannerTablet( modifier = Modifier.padding(top = 16.dp), @@ -491,7 +522,7 @@ fun CalendarSyncCard( fun ExpandableView( sectionKey: DatesSection = DatesSection.NONE, sectionDates: List, - onItemClick: (String) -> Unit, + onItemClick: (CourseDateBlock) -> Unit, ) { var expanded by remember { mutableStateOf(false) } // expandable view Animation @@ -582,7 +613,7 @@ fun ExpandableView( private fun CourseDateBlockSection( sectionKey: DatesSection = DatesSection.NONE, sectionDates: List, - onItemClick: (String) -> Unit, + onItemClick: (CourseDateBlock) -> Unit, ) { Column(modifier = Modifier.padding(start = 8.dp)) { if (sectionKey != DatesSection.COMPLETED) { @@ -635,7 +666,7 @@ private fun DateBullet( @Composable private fun DateBlock( dateBlocks: List, - onItemClick: (String) -> Unit, + onItemClick: (CourseDateBlock) -> Unit, ) { Column( modifier = Modifier @@ -660,7 +691,7 @@ private fun CourseDateItem( dateBlock: CourseDateBlock, canShowDate: Boolean, isMiddleChild: Boolean, - onItemClick: (String) -> Unit + onItemClick: (CourseDateBlock) -> Unit, ) { Column( modifier = Modifier @@ -688,7 +719,7 @@ private fun CourseDateItem( .fillMaxWidth() .padding(end = 4.dp) .clickable(enabled = dateBlock.blockId.isNotEmpty() && dateBlock.learnerHasAccess, - onClick = { onItemClick(dateBlock.blockId) }) + onClick = { onItemClick(dateBlock) }) ) { dateBlock.dateType.drawableResId?.let { icon -> Icon( @@ -756,6 +787,7 @@ private fun CourseDatesScreenPreview() { onReloadClick = {}, onSwipeRefresh = {}, onItemClick = {}, + onPLSBannerViewed = {}, onSyncDates = {}, onCalendarSyncSwitch = {}, ) @@ -778,6 +810,7 @@ private fun CourseDatesScreenTabletPreview() { onReloadClick = {}, onSwipeRefresh = {}, onItemClick = {}, + onPLSBannerViewed = {}, onSyncDates = {}, onCalendarSyncSwitch = {}, ) diff --git a/course/src/main/java/org/openedx/course/presentation/dates/CourseDatesViewModel.kt b/course/src/main/java/org/openedx/course/presentation/dates/CourseDatesViewModel.kt index 0c1754552..8fa8ad43e 100644 --- a/course/src/main/java/org/openedx/course/presentation/dates/CourseDatesViewModel.kt +++ b/course/src/main/java/org/openedx/course/presentation/dates/CourseDatesViewModel.kt @@ -15,6 +15,8 @@ import org.openedx.core.UIMessage 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.CourseBannerType +import org.openedx.core.domain.model.CourseDateBlock import org.openedx.core.extension.getSequentialBlocks import org.openedx.core.extension.getVerticalBlocks import org.openedx.core.extension.isInternetError @@ -25,6 +27,9 @@ import org.openedx.core.system.notifier.CalendarSyncEvent.CreateCalendarSyncEven import org.openedx.core.system.notifier.CourseNotifier import org.openedx.course.DatesShiftedSnackBar import org.openedx.course.domain.interactor.CourseInteractor +import org.openedx.course.presentation.CourseAnalytics +import org.openedx.course.presentation.CourseAnalyticsEvent +import org.openedx.course.presentation.CourseAnalyticsKey import org.openedx.course.presentation.calendarsync.CalendarManager import org.openedx.course.presentation.calendarsync.CalendarSyncDialogType import org.openedx.course.presentation.calendarsync.CalendarSyncUIState @@ -32,14 +37,16 @@ import org.openedx.core.R as CoreR class CourseDatesViewModel( val courseId: String, - var courseName: String, + val courseName: String, val isSelfPaced: Boolean, + private val enrollmentMode: String, private val notifier: CourseNotifier, private val interactor: CourseInteractor, private val calendarManager: CalendarManager, private val networkConnection: NetworkConnection, private val resourceManager: ResourceManager, private val corePreferences: CorePreferences, + private val courseAnalytics: CourseAnalytics, private val config: Config, ) : BaseViewModel() { @@ -68,6 +75,8 @@ class CourseDatesViewModel( val hasInternetConnection: Boolean get() = networkConnection.isOnline() + private var courseBannerType: CourseBannerType = CourseBannerType.BLANK + val isCourseExpandableSectionsEnabled get() = config.isCourseNestedListEnabled() init { @@ -97,6 +106,7 @@ class CourseDatesViewModel( _uiState.value = DatesUIState.Empty } else { _uiState.value = DatesUIState.Dates(datesResponse) + courseBannerType = datesResponse.courseBanner.bannerType checkIfCalendarOutOfDate() } } catch (e: Exception) { @@ -152,6 +162,7 @@ class CourseDatesViewModel( } fun handleCalendarSyncState(isChecked: Boolean) { + logCalendarSyncToggle(isChecked) setCalendarSyncDialogType( when { isChecked && calendarManager.hasPermissions() -> CalendarSyncDialogType.SYNC_DIALOG @@ -204,4 +215,73 @@ class CourseDatesViewModel( return calendarSync.isEnabled && ((calendarSync.isSelfPacedEnabled && isSelfPaced) || (calendarSync.isInstructorPacedEnabled && !isSelfPaced)) } + + fun logPlsBannerViewed() { + logPLSBannerEvent(CourseAnalyticsEvent.PLS_BANNER_VIEWED) + } + + fun logPlsShiftButtonClicked() { + logPLSBannerEvent(CourseAnalyticsEvent.PLS_SHIFT_BUTTON_CLICKED) + } + + fun logPlsShiftDates(isSuccess: Boolean) { + logPLSBannerEvent(CourseAnalyticsEvent.PLS_SHIFT_DATES, isSuccess) + } + + fun logCourseComponentTapped(isSupported: Boolean, block: CourseDateBlock) { + val params = buildMap { + put(CourseAnalyticsKey.BLOCK_ID.key, block.blockId) + put(CourseAnalyticsKey.BLOCK_TYPE.key, block.dateType) + put(CourseAnalyticsKey.LINK.key, block.link) + put(CourseAnalyticsKey.SUPPORTED.key, isSupported) + } + + logDatesEvent(CourseAnalyticsEvent.DATES_COURSE_COMPONENT_CLICKED, params) + } + + private fun logCalendarSyncToggle(isChecked: Boolean) { + logDatesEvent( + CourseAnalyticsEvent.DATES_CALENDAR_SYNC_TOGGLE, + buildMap { + put( + CourseAnalyticsKey.ACTION.key, + if (isChecked) CourseAnalyticsKey.ON.key else CourseAnalyticsKey.OFF.key + ) + } + ) + } + + private fun logDatesEvent( + event: CourseAnalyticsEvent, + param: Map = emptyMap(), + ) { + courseAnalytics.logEvent( + event = event.eventName, + params = buildMap { + put(CourseAnalyticsKey.NAME.key, event.biValue) + put(CourseAnalyticsKey.COURSE_ID.key, courseId) + put(CourseAnalyticsKey.ENROLLMENT_MODE.key, enrollmentMode) + put(CourseAnalyticsKey.PACING.key, isSelfPaced) + putAll(param) + } + ) + } + + private fun logPLSBannerEvent( + event: CourseAnalyticsEvent, + isSuccess: Boolean? = null, + ) { + courseAnalytics.logEvent( + event = event.eventName, + params = buildMap { + put(CourseAnalyticsKey.NAME.key, event.biValue) + put(CourseAnalyticsKey.CATEGORY.key, CourseAnalyticsKey.COURSE_DATES.key) + put(CourseAnalyticsKey.COURSE_ID.key, courseId) + put(CourseAnalyticsKey.ENROLLMENT_MODE.key, enrollmentMode) + put(CourseAnalyticsKey.BANNER_TYPE.key, courseBannerType.name) + put(CourseAnalyticsKey.SCREEN_NAME.key, CourseAnalyticsKey.COURSE_DATES.key) + isSuccess?.let { put(CourseAnalyticsKey.SUCCESS.key, it) } + } + ) + } } diff --git a/course/src/main/java/org/openedx/course/presentation/detail/CourseDetailsFragment.kt b/course/src/main/java/org/openedx/course/presentation/detail/CourseDetailsFragment.kt index 5e3b852ce..56bbb6937 100644 --- a/course/src/main/java/org/openedx/course/presentation/detail/CourseDetailsFragment.kt +++ b/course/src/main/java/org/openedx/course/presentation/detail/CourseDetailsFragment.kt @@ -158,19 +158,16 @@ class CourseDetailsFragment : Fragment() { } currentState.course.isEnrolled -> { - viewModel.viewCourseClickedEvent( - currentState.course.courseId, - currentState.course.name - ) router.navigateToCourseOutline( requireActivity().supportFragmentManager, currentState.course.courseId, - currentState.course.name + currentState.course.name, + "", ) } else -> { - viewModel.enrollInACourse(currentState.course.courseId) + viewModel.enrollInACourse(currentState.course.courseId, currentState.course.name) } } } diff --git a/course/src/main/java/org/openedx/course/presentation/detail/CourseDetailsViewModel.kt b/course/src/main/java/org/openedx/course/presentation/detail/CourseDetailsViewModel.kt index 8a217de14..ddc81f2c5 100644 --- a/course/src/main/java/org/openedx/course/presentation/detail/CourseDetailsViewModel.kt +++ b/course/src/main/java/org/openedx/course/presentation/detail/CourseDetailsViewModel.kt @@ -18,6 +18,8 @@ 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 org.openedx.course.presentation.CourseAnalyticsEvent +import org.openedx.course.presentation.CourseAnalyticsKey class CourseDetailsViewModel( val courseId: String, @@ -27,7 +29,7 @@ class CourseDetailsViewModel( private val interactor: CourseInteractor, private val resourceManager: ResourceManager, private val notifier: CourseNotifier, - private val analytics: CourseAnalytics + private val analytics: CourseAnalytics, ) : BaseViewModel() { val apiHostUrl get() = config.getApiHostURL() val isUserLoggedIn get() = corePreferences.user != null @@ -78,18 +80,18 @@ class CourseDetailsViewModel( } } - fun enrollInACourse(id: String) { + fun enrollInACourse(id: String, title: String) { viewModelScope.launch { try { val courseData = _uiState.value if (courseData is CourseDetailsUIState.CourseData) { - courseEnrollClickedEvent(id, courseData.course.name) + courseEnrollClickedEvent(id, title) } interactor.enrollInACourse(id) val course = interactor.getCourseDetails(id) if (courseData is CourseDetailsUIState.CourseData) { _uiState.value = courseData.copy(course = course) - courseEnrollSuccessEvent(id, course.name) + courseEnrollSuccessEvent(id, title) notifier.send(CourseDashboardUpdate()) } } catch (e: Exception) { @@ -127,15 +129,26 @@ class CourseDetailsViewModel( return java.lang.Long.toHexString(color.toLong()).substring(2, 8) } - private fun courseEnrollClickedEvent(courseId: String, courseName: String) { - analytics.courseEnrollClickedEvent(courseId, courseName) + private fun courseEnrollClickedEvent(courseId: String, courseTitle: String) { + logEvent(CourseAnalyticsEvent.COURSE_ENROLL_CLICKED, courseId, courseTitle) } - private fun courseEnrollSuccessEvent(courseId: String, courseName: String) { - analytics.courseEnrollSuccessEvent(courseId, courseName) + private fun courseEnrollSuccessEvent(courseId: String, courseTitle: String) { + logEvent(CourseAnalyticsEvent.COURSE_ENROLL_SUCCESS, courseId, courseTitle) } - fun viewCourseClickedEvent(courseId: String, courseName: String) { - analytics.viewCourseClickedEvent(courseId, courseName) + private fun logEvent( + event: CourseAnalyticsEvent, + courseId: String, courseTitle: String, + ) { + analytics.logEvent( + event.eventName, + buildMap { + put(CourseAnalyticsKey.NAME.key, event.biValue) + put(CourseAnalyticsKey.COURSE_ID.key, courseId) + put(CourseAnalyticsKey.COURSE_NAME.key, courseTitle) + put(CourseAnalyticsKey.CONVERSION.key, courseId) + } + ) } } diff --git a/course/src/main/java/org/openedx/course/presentation/handouts/HandoutsViewModel.kt b/course/src/main/java/org/openedx/course/presentation/handouts/HandoutsViewModel.kt index 117e8130a..66ba39293 100644 --- a/course/src/main/java/org/openedx/course/presentation/handouts/HandoutsViewModel.kt +++ b/course/src/main/java/org/openedx/course/presentation/handouts/HandoutsViewModel.kt @@ -9,12 +9,16 @@ import org.openedx.core.config.Config import org.openedx.core.domain.model.AnnouncementModel import org.openedx.core.domain.model.HandoutsModel import org.openedx.course.domain.interactor.CourseInteractor +import org.openedx.course.presentation.CourseAnalytics +import org.openedx.course.presentation.CourseAnalyticsEvent +import org.openedx.course.presentation.CourseAnalyticsKey class HandoutsViewModel( private val courseId: String, + val handoutsType: String, private val config: Config, - private val handoutsType: String, - private val interactor: CourseInteractor + private val interactor: CourseInteractor, + private val courseAnalytics: CourseAnalytics, ) : BaseViewModel() { val apiHostUrl get() = config.getApiHostURL() @@ -93,5 +97,13 @@ class HandoutsViewModel( return java.lang.Long.toHexString(color.toLong()).substring(2, 8) } - -} \ No newline at end of file + fun logEvent(event: CourseAnalyticsEvent) { + courseAnalytics.logEvent( + event = event.eventName, + params = buildMap { + put(CourseAnalyticsKey.NAME.key, event.biValue) + put(CourseAnalyticsKey.COURSE_ID.key, courseId) + } + ) + } +} diff --git a/course/src/main/java/org/openedx/course/presentation/handouts/HandoutsWebViewFragment.kt b/course/src/main/java/org/openedx/course/presentation/handouts/HandoutsWebViewFragment.kt index cb1816b47..7c9d3615e 100644 --- a/course/src/main/java/org/openedx/course/presentation/handouts/HandoutsWebViewFragment.kt +++ b/course/src/main/java/org/openedx/course/presentation/handouts/HandoutsWebViewFragment.kt @@ -22,6 +22,7 @@ import org.openedx.core.ui.WindowType import org.openedx.core.ui.rememberWindowSize import org.openedx.core.ui.theme.OpenEdXTheme import org.openedx.core.ui.theme.appColors +import org.openedx.course.presentation.CourseAnalyticsEvent class HandoutsWebViewFragment : Fragment() { @@ -35,7 +36,7 @@ class HandoutsWebViewFragment : Fragment() { override fun onCreateView( inflater: LayoutInflater, container: ViewGroup?, - savedInstanceState: Bundle? + savedInstanceState: Bundle?, ) = ComposeView(requireContext()).apply { setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed) setContent { @@ -60,6 +61,11 @@ class HandoutsWebViewFragment : Fragment() { }) } } + if (HandoutsType.valueOf(viewModel.handoutsType) == HandoutsType.Handouts) { + viewModel.logEvent(CourseAnalyticsEvent.HANDOUTS) + } else { + viewModel.logEvent(CourseAnalyticsEvent.ANNOUNCEMENTS) + } } companion object { @@ -70,7 +76,7 @@ class HandoutsWebViewFragment : Fragment() { fun newInstance( title: String, type: String, - courseId: String + courseId: String, ): HandoutsWebViewFragment { val fragment = HandoutsWebViewFragment() fragment.arguments = bundleOf( diff --git a/course/src/main/java/org/openedx/course/presentation/info/CourseInfoFragment.kt b/course/src/main/java/org/openedx/course/presentation/info/CourseInfoFragment.kt index a2f8ba0ec..0f727d4b1 100644 --- a/course/src/main/java/org/openedx/course/presentation/info/CourseInfoFragment.kt +++ b/course/src/main/java/org/openedx/course/presentation/info/CourseInfoFragment.kt @@ -42,6 +42,7 @@ import androidx.fragment.app.Fragment import org.koin.androidx.viewmodel.ext.android.viewModel import org.koin.core.parameter.parametersOf import org.openedx.core.UIMessage +import org.openedx.core.presentation.CoreAnalyticsScreen import org.openedx.core.presentation.catalog.CatalogWebViewScreen import org.openedx.core.presentation.catalog.WebViewLink import org.openedx.core.presentation.dialog.alert.ActionDialogFragment @@ -142,8 +143,17 @@ class CourseInfoFragment : Fragment() { }, onUriClick = { param, type -> when (type) { - linkAuthority.PROGRAM_INFO, + linkAuthority.PROGRAM_INFO -> { + viewModel.programInfoClickedEvent(param) + viewModel.infoCardClicked( + fragmentManager = requireActivity().supportFragmentManager, + pathId = param, + infoType = type.name + ) + } + linkAuthority.COURSE_INFO -> { + viewModel.courseInfoClickedEvent(param) viewModel.infoCardClicked( fragmentManager = requireActivity().supportFragmentManager, pathId = param, @@ -159,6 +169,7 @@ class CourseInfoFragment : Fragment() { getString(CoreR.string.platform_name) ), url = param, + source = CoreAnalyticsScreen.COURSE_INFO.screenName ).show( requireActivity().supportFragmentManager, ActionDialogFragment::class.simpleName @@ -166,6 +177,7 @@ class CourseInfoFragment : Fragment() { } linkAuthority.ENROLL -> { + viewModel.courseEnrollClickedEvent(param) if (uiState.isPreLogin) { viewModel.navigateToSignUp( fragmentManager = requireActivity().supportFragmentManager, diff --git a/course/src/main/java/org/openedx/course/presentation/info/CourseInfoViewModel.kt b/course/src/main/java/org/openedx/course/presentation/info/CourseInfoViewModel.kt index f2da38168..b5679e0c1 100644 --- a/course/src/main/java/org/openedx/course/presentation/info/CourseInfoViewModel.kt +++ b/course/src/main/java/org/openedx/course/presentation/info/CourseInfoViewModel.kt @@ -14,6 +14,7 @@ import org.openedx.core.UIMessage import org.openedx.core.config.Config import org.openedx.core.data.storage.CorePreferences import org.openedx.core.extension.isInternetError +import org.openedx.core.presentation.CoreAnalyticsKey import org.openedx.core.presentation.catalog.WebViewLink import org.openedx.core.system.ResourceManager import org.openedx.core.system.connection.NetworkConnection @@ -21,20 +22,24 @@ import org.openedx.core.system.notifier.CourseDashboardUpdate import org.openedx.core.system.notifier.CourseNotifier import org.openedx.course.R import org.openedx.course.domain.interactor.CourseInteractor +import org.openedx.course.presentation.CourseAnalytics +import org.openedx.course.presentation.CourseAnalyticsEvent +import org.openedx.course.presentation.CourseAnalyticsKey import org.openedx.course.presentation.CourseRouter import java.util.concurrent.atomic.AtomicReference import org.openedx.core.R as CoreR class CourseInfoViewModel( + val pathId: String, + val infoType: String, private val config: Config, private val networkConnection: NetworkConnection, private val router: CourseRouter, private val interactor: CourseInteractor, private val notifier: CourseNotifier, private val resourceManager: ResourceManager, + private val analytics: CourseAnalytics, corePreferences: CorePreferences, - val pathId: String, - val infoType: String, ) : BaseViewModel() { private val _uiState = @@ -89,6 +94,7 @@ class CourseInfoViewModel( } interactor.enrollInACourse(courseId) + courseEnrollSuccessEvent(courseId) notifier.send(CourseDashboardUpdate()) _uiState.update { it.copy(enrollmentSuccess = AtomicReference(courseId)) } } catch (e: Exception) { @@ -108,7 +114,8 @@ class CourseInfoViewModel( router.navigateToCourseOutline( fm = fragmentManager, courseId = courseId, - courseTitle = "" + courseTitle = "", + enrollmentMode = "" ) } } @@ -131,6 +138,37 @@ class CourseInfoViewModel( router.navigateToSignIn(fragmentManager, courseId, infoType) } + fun courseInfoClickedEvent(courseId: String) { + logEvent(CourseAnalyticsEvent.COURSE_INFO, courseId) + } + + fun programInfoClickedEvent(courseId: String) { + logEvent(CourseAnalyticsEvent.PROGRAM_INFO, courseId) + } + + fun courseEnrollClickedEvent(courseId: String) { + logEvent(CourseAnalyticsEvent.COURSE_ENROLL_CLICKED, courseId) + } + + private fun courseEnrollSuccessEvent(courseId: String) { + logEvent(CourseAnalyticsEvent.COURSE_ENROLL_SUCCESS, courseId) + } + + private fun logEvent( + event: CourseAnalyticsEvent, + courseId: String, + ) { + analytics.logEvent( + event.eventName, + buildMap { + put(CourseAnalyticsKey.NAME.key, event.biValue) + put(CourseAnalyticsKey.COURSE_ID.key, courseId) + put(CourseAnalyticsKey.CATEGORY.key, CoreAnalyticsKey.DISCOVERY.key) + put(CourseAnalyticsKey.CONVERSION.key, courseId) + } + ) + } + companion object { private const val ARG_PATH_ID = "path_id" } diff --git a/course/src/main/java/org/openedx/course/presentation/outline/CourseOutlineFragment.kt b/course/src/main/java/org/openedx/course/presentation/outline/CourseOutlineFragment.kt index 4d9dfc54b..64ed6758e 100644 --- a/course/src/main/java/org/openedx/course/presentation/outline/CourseOutlineFragment.kt +++ b/course/src/main/java/org/openedx/course/presentation/outline/CourseOutlineFragment.kt @@ -48,6 +48,7 @@ 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.AndroidUriHandler import androidx.compose.ui.platform.ComposeView import androidx.compose.ui.platform.ViewCompositionStrategy import androidx.compose.ui.res.painterResource @@ -70,6 +71,7 @@ import org.openedx.core.domain.model.BlockCounts import org.openedx.core.domain.model.CourseDatesBannerInfo import org.openedx.core.domain.model.CourseStructure import org.openedx.core.domain.model.CoursewareAccess +import org.openedx.core.extension.takeIfNotEmpty import org.openedx.core.presentation.course.CourseViewMode import org.openedx.core.ui.HandleUIMessage import org.openedx.core.ui.OfflineModeDialog @@ -159,7 +161,7 @@ class CourseOutlineFragment : Fragment() { }, onSubSectionClick = { subSectionBlock -> viewModel.courseSubSectionUnit[subSectionBlock.id]?.let { unit -> - viewModel.verticalClickedEvent(unit.blockId, unit.displayName) + viewModel.logUnitDetailViewedEvent(unit.blockId, unit.displayName) router.navigateToCourseContainer( requireActivity().supportFragmentManager, courseId = viewModel.courseId, @@ -218,6 +220,11 @@ class CourseOutlineFragment : Fragment() { }, onViewDates = { (parentFragment as CourseContainerFragment).navigateToTab(CourseContainerTab.DATES) + }, + onCertificateClick = { + viewModel.viewCertificateTappedEvent() + it.takeIfNotEmpty() + ?.let { url -> AndroidUriHandler(requireContext()).openUri(url) } } ) } @@ -229,7 +236,7 @@ class CourseOutlineFragment : Fragment() { private const val ARG_TITLE = "title" fun newInstance( courseId: String, - title: String + title: String, ): CourseOutlineFragment { val fragment = CourseOutlineFragment() fragment.arguments = bundleOf( @@ -271,6 +278,7 @@ internal fun CourseOutlineScreen( onDownloadClick: (Block) -> Unit, onResetDatesClick: () -> Unit, onViewDates: () -> Unit?, + onCertificateClick: (String) -> Unit, ) { val scaffoldState = rememberScaffoldState() val pullRefreshState = @@ -364,6 +372,7 @@ internal fun CourseOutlineScreen( courseImage = uiState.courseStructure.media?.image?.large ?: "", courseCertificate = uiState.courseStructure.certificate, + onCertificateClick = onCertificateClick, courseName = uiState.courseStructure.name ) } @@ -667,6 +676,7 @@ private fun CourseOutlineScreenPreview() { onDownloadClick = {}, onResetDatesClick = {}, onViewDates = {}, + onCertificateClick = {}, ) } } @@ -708,6 +718,7 @@ private fun CourseOutlineScreenTabletPreview() { onDownloadClick = {}, onResetDatesClick = {}, onViewDates = {}, + onCertificateClick = {}, ) } } diff --git a/course/src/main/java/org/openedx/course/presentation/outline/CourseOutlineViewModel.kt b/course/src/main/java/org/openedx/course/presentation/outline/CourseOutlineViewModel.kt index b129fcd13..eeabab539 100644 --- a/course/src/main/java/org/openedx/course/presentation/outline/CourseOutlineViewModel.kt +++ b/course/src/main/java/org/openedx/course/presentation/outline/CourseOutlineViewModel.kt @@ -22,6 +22,7 @@ import org.openedx.core.extension.isInternetError import org.openedx.core.module.DownloadWorkerController import org.openedx.core.module.db.DownloadDao import org.openedx.core.module.download.BaseDownloadViewModel +import org.openedx.core.presentation.CoreAnalytics import org.openedx.core.system.ResourceManager import org.openedx.core.system.connection.NetworkConnection import org.openedx.core.system.notifier.CalendarSyncEvent.CreateCalendarSyncEvent @@ -30,6 +31,8 @@ import org.openedx.core.system.notifier.CourseStructureUpdated import org.openedx.course.DatesShiftedSnackBar import org.openedx.course.domain.interactor.CourseInteractor import org.openedx.course.presentation.CourseAnalytics +import org.openedx.course.presentation.CourseAnalyticsEvent +import org.openedx.course.presentation.CourseAnalyticsKey import org.openedx.course.presentation.calendarsync.CalendarSyncDialogType import org.openedx.course.R as courseR @@ -42,9 +45,16 @@ class CourseOutlineViewModel( private val networkConnection: NetworkConnection, private val preferencesManager: CorePreferences, private val analytics: CourseAnalytics, + coreAnalytics: CoreAnalytics, downloadDao: DownloadDao, - workerController: DownloadWorkerController -) : BaseDownloadViewModel(downloadDao, preferencesManager, workerController) { + workerController: DownloadWorkerController, +) : BaseDownloadViewModel( + courseId, + downloadDao, + preferencesManager, + workerController, + coreAnalytics +) { val apiHostUrl get() = config.getApiHostURL() @@ -256,7 +266,7 @@ class CourseOutlineViewModel( private fun getResumeBlock( blocks: List, - continueBlockId: String + continueBlockId: String, ): Block? { val resumeBlock = blocks.firstOrNull { it.id == continueBlockId } resumeVerticalBlock = @@ -286,10 +296,31 @@ class CourseOutlineViewModel( } } + fun viewCertificateTappedEvent() { + analytics.logEvent( + CourseAnalyticsEvent.VIEW_CERTIFICATE.eventName, + buildMap { + put(CourseAnalyticsKey.NAME.key, CourseAnalyticsEvent.VIEW_CERTIFICATE.biValue) + put(CourseAnalyticsKey.COURSE_ID.key, courseId) + } + ) + } + fun resumeCourseTappedEvent(blockId: String) { val currentState = uiState.value if (currentState is CourseOutlineUIState.CourseData) { - analytics.resumeCourseTappedEvent(courseId, currentState.courseStructure.name, blockId) + analytics.logEvent( + CourseAnalyticsEvent.RESUME_COURSE_CLICKED.eventName, + buildMap { + put( + CourseAnalyticsKey.NAME.key, + CourseAnalyticsEvent.RESUME_COURSE_CLICKED.biValue + ) + put(CourseAnalyticsKey.COURSE_ID.key, courseId) + put(CourseAnalyticsKey.COURSE_NAME.key, courseTitle) + put(CourseAnalyticsKey.BLOCK_ID.key, blockId) + } + ) } } @@ -305,10 +336,19 @@ class CourseOutlineViewModel( } } - fun verticalClickedEvent(blockId: String, blockName: String) { + fun logUnitDetailViewedEvent(blockId: String, blockName: String) { val currentState = uiState.value if (currentState is CourseOutlineUIState.CourseData) { - analytics.verticalClickedEvent(courseId, courseTitle, blockId, blockName) + analytics.logEvent( + CourseAnalyticsEvent.UNIT_DETAIL.eventName, + buildMap { + put(CourseAnalyticsKey.NAME.key, CourseAnalyticsEvent.UNIT_DETAIL.biValue) + put(CourseAnalyticsKey.COURSE_ID.key, courseId) + put(CourseAnalyticsKey.COURSE_NAME.key, courseTitle) + put(CourseAnalyticsKey.BLOCK_ID.key, blockId) + put(CourseAnalyticsKey.BLOCK_NAME.key, blockName) + } + ) } } diff --git a/course/src/main/java/org/openedx/course/presentation/section/CourseSectionFragment.kt b/course/src/main/java/org/openedx/course/presentation/section/CourseSectionFragment.kt index 882bafc9b..297545117 100644 --- a/course/src/main/java/org/openedx/course/presentation/section/CourseSectionFragment.kt +++ b/course/src/main/java/org/openedx/course/presentation/section/CourseSectionFragment.kt @@ -86,7 +86,7 @@ class CourseSectionFragment : Fragment() { }, onItemClick = { block -> if (block.descendants.isNotEmpty()) { - viewModel.verticalClickedEvent(block.blockId, block.displayName) + viewModel.verticalClickedEvent(block.blockId) router.navigateToCourseContainer( fm = requireActivity().supportFragmentManager, courseId = viewModel.courseId, diff --git a/course/src/main/java/org/openedx/course/presentation/section/CourseSectionViewModel.kt b/course/src/main/java/org/openedx/course/presentation/section/CourseSectionViewModel.kt index aaa6e99d0..97f241650 100644 --- a/course/src/main/java/org/openedx/course/presentation/section/CourseSectionViewModel.kt +++ b/course/src/main/java/org/openedx/course/presentation/section/CourseSectionViewModel.kt @@ -4,6 +4,7 @@ import androidx.lifecycle.LifecycleOwner import androidx.lifecycle.LiveData import androidx.lifecycle.MutableLiveData import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.launch import org.openedx.core.BlockType import org.openedx.core.R import org.openedx.core.SingleEventLiveData @@ -14,6 +15,7 @@ import org.openedx.core.extension.isInternetError import org.openedx.core.module.DownloadWorkerController import org.openedx.core.module.db.DownloadDao import org.openedx.core.module.download.BaseDownloadViewModel +import org.openedx.core.presentation.CoreAnalytics import org.openedx.core.presentation.course.CourseViewMode import org.openedx.core.system.ResourceManager import org.openedx.core.system.connection.NetworkConnection @@ -21,19 +23,27 @@ import org.openedx.core.system.notifier.CourseNotifier import org.openedx.core.system.notifier.CourseSectionChanged import org.openedx.course.domain.interactor.CourseInteractor import org.openedx.course.presentation.CourseAnalytics -import kotlinx.coroutines.launch +import org.openedx.course.presentation.CourseAnalyticsEvent +import org.openedx.course.presentation.CourseAnalyticsKey class CourseSectionViewModel( + val courseId: String, private val interactor: CourseInteractor, private val resourceManager: ResourceManager, private val networkConnection: NetworkConnection, private val preferencesManager: CorePreferences, private val notifier: CourseNotifier, private val analytics: CourseAnalytics, + coreAnalytics: CoreAnalytics, workerController: DownloadWorkerController, downloadDao: DownloadDao, - val courseId: String -) : BaseDownloadViewModel(downloadDao, preferencesManager, workerController) { +) : BaseDownloadViewModel( + courseId, + downloadDao, + preferencesManager, + workerController, + coreAnalytics +) { private val _uiState = MutableLiveData(CourseSectionUIState.Loading) val uiState: LiveData @@ -56,8 +66,10 @@ class CourseSectionViewModel( sectionName = state.sectionName, courseName = state.courseName, blocks = ArrayList(list), - downloadedState = downloadModels.toMap()) + downloadedState = downloadModels.toMap() + ) } + else -> {} } } @@ -141,10 +153,18 @@ class CourseSectionViewModel( } } - fun verticalClickedEvent(blockId: String, blockName: String) { + fun verticalClickedEvent(blockId: String) { val currentState = uiState.value if (currentState is CourseSectionUIState.Blocks) { - analytics.verticalClickedEvent(courseId, currentState.courseName, blockId, blockName) + analytics.logEvent( + event = CourseAnalyticsEvent.UNIT_DETAIL.eventName, + params = buildMap { + put(CourseAnalyticsKey.NAME.key, CourseAnalyticsEvent.UNIT_DETAIL.biValue) + put(CourseAnalyticsKey.COURSE_ID.key, courseId) + put(CourseAnalyticsKey.BLOCK_ID.key, blockId) + put(CourseAnalyticsKey.CATEGORY.key, CourseAnalyticsKey.NAVIGATION.key) + } + ) } } -} \ No newline at end of file +} diff --git a/course/src/main/java/org/openedx/course/presentation/ui/CourseUI.kt b/course/src/main/java/org/openedx/course/presentation/ui/CourseUI.kt index 23c31e843..088f28d72 100644 --- a/course/src/main/java/org/openedx/course/presentation/ui/CourseUI.kt +++ b/course/src/main/java/org/openedx/course/presentation/ui/CourseUI.kt @@ -66,7 +66,6 @@ import androidx.compose.ui.graphics.painter.Painter import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalConfiguration import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.platform.LocalUriHandler import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource @@ -122,7 +121,8 @@ fun CourseImageHeader( apiHostUrl: String, courseImage: String?, courseCertificate: Certificate?, - courseName: String + onCertificateClick: (String) -> Unit = {}, + courseName: String, ) { val configuration = LocalConfiguration.current val windowSize = rememberWindowSize() @@ -132,7 +132,6 @@ fun CourseImageHeader( } else { ContentScale.Crop } - val uriHandler = LocalUriHandler.current val imageUrl = if (courseImage?.isLinkValid() == true) { courseImage } else { @@ -191,7 +190,7 @@ fun CourseImageHeader( text = stringResource(id = R.string.course_view_certificate), onClick = { courseCertificate.certificateURL?.let { - uriHandler.openUri(it) + onCertificateClick(it) } }) } @@ -578,6 +577,7 @@ fun HorizontalPageIndicator( @Composable fun VerticalPageIndicator( + modifier: Modifier = Modifier, numberOfPages: Int, selectedPage: Int = 0, selectedColor: Color = Color.White, @@ -585,7 +585,6 @@ fun VerticalPageIndicator( defaultRadius: Dp = 8.dp, selectedLength: Dp = 25.dp, space: Dp = 4.dp, - modifier: Modifier = Modifier ) { Column( verticalArrangement = Arrangement.Center, @@ -1342,7 +1341,7 @@ private fun NavigationUnitsButtonsWithNextPreview() { @Preview(uiMode = Configuration.UI_MODE_NIGHT_YES) @Composable private fun SequentialItemPreview() { - OpenEdXTheme() { + OpenEdXTheme { Surface(color = MaterialTheme.appColors.background) { SequentialItem(block = mockChapterBlock, onClick = {}) } diff --git a/course/src/main/java/org/openedx/course/presentation/unit/container/CourseUnitContainerFragment.kt b/course/src/main/java/org/openedx/course/presentation/unit/container/CourseUnitContainerFragment.kt index a4d6de5bb..9f868651e 100644 --- a/course/src/main/java/org/openedx/course/presentation/unit/container/CourseUnitContainerFragment.kt +++ b/course/src/main/java/org/openedx/course/presentation/unit/container/CourseUnitContainerFragment.kt @@ -57,12 +57,14 @@ class CourseUnitContainerFragment : Fragment(R.layout.fragment_course_unit_conta private var _binding: FragmentCourseUnitContainerBinding? = null private val viewModel by viewModel { - parametersOf(requireArguments().getString(ARG_COURSE_ID, "")) + parametersOf( + requireArguments().getString(ARG_COURSE_ID, ""), + requireArguments().getString(UNIT_ID, "") + ) } private val router by inject() - private var unitId: String = "" private var componentId: String = "" private lateinit var adapter: CourseUnitContainerAdapter @@ -132,10 +134,10 @@ class CourseUnitContainerFragment : Fragment(R.layout.fragment_course_unit_conta override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) lifecycle.addObserver(viewModel) - unitId = requireArguments().getString(UNIT_ID, "") componentId = requireArguments().getString(ARG_COMPONENT_ID, "") viewModel.loadBlocks(requireArguments().serializable(ARG_MODE)!!) - viewModel.setupCurrentIndex(unitId, componentId) + viewModel.setupCurrentIndex(componentId) + viewModel.courseUnitContainerShowedEvent() } override fun onCreateView( @@ -244,7 +246,7 @@ class CourseUnitContainerFragment : Fragment(R.layout.fragment_course_unit_conta if (viewModel.isCourseExpandableSectionsEnabled) { binding.subSectionUnitsTitle.setContent { val unitBlocks by viewModel.subSectionUnitBlocks.collectAsState() - val currentUnit = unitBlocks.firstOrNull { it.id == unitId } + val currentUnit = unitBlocks.firstOrNull { it.id == viewModel.unitId } val unitName = currentUnit?.displayName ?: "" val unitsListShowed by viewModel.unitsListShowed.observeAsState(false) @@ -262,7 +264,7 @@ class CourseUnitContainerFragment : Fragment(R.layout.fragment_course_unit_conta binding.subSectionUnitsList.setContent { val unitBlocks by viewModel.subSectionUnitBlocks.collectAsState() - val selectedUnitIndex = unitBlocks.indexOfFirst { it.id == unitId } + val selectedUnitIndex = unitBlocks.indexOfFirst { it.id == viewModel.unitId } OpenEdXTheme { SubSectionUnitsList( unitBlocks = unitBlocks, diff --git a/course/src/main/java/org/openedx/course/presentation/unit/container/CourseUnitContainerViewModel.kt b/course/src/main/java/org/openedx/course/presentation/unit/container/CourseUnitContainerViewModel.kt index 0b6df71d4..323adb7cb 100644 --- a/course/src/main/java/org/openedx/course/presentation/unit/container/CourseUnitContainerViewModel.kt +++ b/course/src/main/java/org/openedx/course/presentation/unit/container/CourseUnitContainerViewModel.kt @@ -23,13 +23,16 @@ import org.openedx.core.system.notifier.CourseSectionChanged import org.openedx.core.system.notifier.CourseStructureUpdated import org.openedx.course.domain.interactor.CourseInteractor import org.openedx.course.presentation.CourseAnalytics +import org.openedx.course.presentation.CourseAnalyticsEvent +import org.openedx.course.presentation.CourseAnalyticsKey class CourseUnitContainerViewModel( + val courseId: String, + val unitId: String, private val config: Config, private val interactor: CourseInteractor, private val notifier: CourseNotifier, private val analytics: CourseAnalytics, - val courseId: String ) : BaseViewModel() { private val blocks = ArrayList() @@ -110,7 +113,7 @@ class CourseUnitContainerViewModel( } } - fun setupCurrentIndex(unitId: String, componentId: String = "") { + fun setupCurrentIndex(componentId: String = "") { if (currentSectionIndex != -1) { return } @@ -248,6 +251,18 @@ class CourseUnitContainerViewModel( return blocks.first { it.descendants.contains(unitId) } } + fun courseUnitContainerShowedEvent() { + analytics.logEvent( + CourseAnalyticsEvent.UNIT_DETAIL.eventName, + buildMap { + put(CourseAnalyticsKey.NAME.key, CourseAnalyticsEvent.UNIT_DETAIL.biValue) + put(CourseAnalyticsKey.COURSE_ID.key, courseId) + put(CourseAnalyticsKey.COURSE_NAME.key, courseName) + put(CourseAnalyticsKey.BLOCK_ID.key, unitId) + } + ) + } + fun nextBlockClickedEvent(blockId: String, blockName: String) { analytics.nextBlockClickedEvent(courseId, courseName, blockId, blockName) } @@ -271,4 +286,4 @@ class CourseUnitContainerViewModel( fun setUnitsListVisibility(isVisible: Boolean) { _unitsListShowed.value = isVisible } -} \ No newline at end of file +} diff --git a/course/src/main/java/org/openedx/course/presentation/unit/video/BaseVideoViewModel.kt b/course/src/main/java/org/openedx/course/presentation/unit/video/BaseVideoViewModel.kt new file mode 100644 index 000000000..96d285223 --- /dev/null +++ b/course/src/main/java/org/openedx/course/presentation/unit/video/BaseVideoViewModel.kt @@ -0,0 +1,95 @@ +package org.openedx.course.presentation.unit.video + +import org.openedx.core.BaseViewModel +import org.openedx.course.presentation.CourseAnalytics +import org.openedx.course.presentation.CourseAnalyticsEvent +import org.openedx.course.presentation.CourseAnalyticsKey + +open class BaseVideoViewModel( + private val courseId: String, + private val courseAnalytics: CourseAnalytics, +) : BaseViewModel() { + + fun logVideoSpeedEvent(videoUrl: String, speed: Float, currentVideoTime: Long, medium: String) { + logVideoEvent( + event = CourseAnalyticsEvent.VIDEO_CHANGE_SPEED, + params = buildMap { + put(CourseAnalyticsKey.OPEN_IN_BROWSER.key, videoUrl) + put(CourseAnalyticsKey.SPEED.key, speed) + put(CourseAnalyticsKey.CURRENT_TIME.key, currentVideoTime) + put(CourseAnalyticsKey.PLAY_MEDIUM.key, medium) + } + ) + } + + fun logVideoSeekEvent( + videoUrl: String, + duration: Long, + currentVideoTime: Long, + medium: String, + ) { + logVideoEvent( + event = CourseAnalyticsEvent.VIDEO_SEEKED, + params = buildMap { + put(CourseAnalyticsKey.OPEN_IN_BROWSER.key, videoUrl) + put(CourseAnalyticsKey.SKIP_INTERVAL.key, duration) + put(CourseAnalyticsKey.CURRENT_TIME.key, currentVideoTime) + put(CourseAnalyticsKey.PLAY_MEDIUM.key, medium) + } + ) + } + + fun logLoadedCompletedEvent( + videoUrl: String, + isLoaded: Boolean, + currentVideoTime: Long, + medium: String, + ) { + logVideoEvent( + event = if (isLoaded) CourseAnalyticsEvent.VIDEO_LOADED else CourseAnalyticsEvent.VIDEO_COMPLETED, + params = buildMap { + put(CourseAnalyticsKey.OPEN_IN_BROWSER.key, videoUrl) + put(CourseAnalyticsKey.CURRENT_TIME.key, currentVideoTime) + put(CourseAnalyticsKey.PLAY_MEDIUM.key, medium) + } + ) + } + + fun logPlayPauseEvent( + videoUrl: String, + isPlaying: Boolean, + currentVideoTime: Long, + medium: String, + ) { + logVideoEvent( + event = if (isPlaying) CourseAnalyticsEvent.VIDEO_PLAYED else CourseAnalyticsEvent.VIDEO_PAUSED, + params = buildMap { + put(CourseAnalyticsKey.OPEN_IN_BROWSER.key, videoUrl) + put(CourseAnalyticsKey.CURRENT_TIME.key, currentVideoTime) + put(CourseAnalyticsKey.PLAY_MEDIUM.key, medium) + } + ) + } + + private fun logVideoEvent(event: CourseAnalyticsEvent, params: Map) { + courseAnalytics.logEvent( + event = event.eventName, + params = buildMap { + put(CourseAnalyticsKey.NAME.key, event.biValue) + put(CourseAnalyticsKey.COURSE_ID.key, courseId) + put(CourseAnalyticsKey.COMPONENT.key, CourseAnalyticsKey.VIDEO_PLAYER.key) + putAll(params) + } + ) + } + + fun logCastConnection(event: CourseAnalyticsEvent) { + courseAnalytics.logEvent( + event = event.eventName, + params = buildMap { + put(CourseAnalyticsKey.NAME.key, event.biValue) + put(CourseAnalyticsKey.PLAY_MEDIUM.key, CourseAnalyticsKey.GOOGLE_CAST.key) + } + ) + } +} diff --git a/course/src/main/java/org/openedx/course/presentation/unit/video/EncodedVideoUnitViewModel.kt b/course/src/main/java/org/openedx/course/presentation/unit/video/EncodedVideoUnitViewModel.kt index c49357161..dec6f70e9 100644 --- a/course/src/main/java/org/openedx/course/presentation/unit/video/EncodedVideoUnitViewModel.kt +++ b/course/src/main/java/org/openedx/course/presentation/unit/video/EncodedVideoUnitViewModel.kt @@ -1,10 +1,12 @@ package org.openedx.course.presentation.unit.video +import android.annotation.SuppressLint import android.content.Context import androidx.lifecycle.LifecycleOwner import androidx.lifecycle.LiveData import androidx.lifecycle.MutableLiveData import androidx.media3.cast.CastPlayer +import androidx.media3.common.PlaybackParameters import androidx.media3.common.Player import androidx.media3.common.util.Clock import androidx.media3.exoplayer.DefaultLoadControl @@ -23,23 +25,28 @@ import org.openedx.core.module.TranscriptManager import org.openedx.core.system.connection.NetworkConnection import org.openedx.core.system.notifier.CourseNotifier import org.openedx.course.data.repository.CourseRepository +import org.openedx.course.presentation.CourseAnalytics +import org.openedx.course.presentation.CourseAnalyticsKey import java.util.concurrent.Executors +@SuppressLint("StaticFieldLeak") class EncodedVideoUnitViewModel( courseId: String, val blockId: String, + private val context: Context, + private val preferencesManager: CorePreferences, courseRepository: CourseRepository, notifier: CourseNotifier, networkConnection: NetworkConnection, transcriptManager: TranscriptManager, - val preferencesManager: CorePreferences, - private val context: Context, + courseAnalytics: CourseAnalytics, ) : VideoUnitViewModel( courseId, courseRepository, notifier, networkConnection, - transcriptManager + transcriptManager, + courseAnalytics ) { private val _isVideoEnded = MutableLiveData(false) @@ -48,6 +55,8 @@ class EncodedVideoUnitViewModel( var exoPlayer: ExoPlayer? = null private set + + @SuppressLint("UnsafeOptInUsageError") var castPlayer: CastPlayer? = null private set @@ -65,8 +74,24 @@ class EncodedVideoUnitViewModel( super.onPlaybackStateChanged(playbackState) if (playbackState == Player.STATE_ENDED) { _isVideoEnded.value = true - markBlockCompleted(blockId) + markBlockCompleted(blockId, CourseAnalyticsKey.NATIVE.key) } + + } + + override fun onIsPlayingChanged(isPlaying: Boolean) { + super.onIsPlayingChanged(isPlaying) + logPlayPauseEvent(videoUrl, isPlaying, getCurrentVideoTime(), getPlayingMedium()) + } + + override fun onPlaybackParametersChanged(playbackParameters: PlaybackParameters) { + super.onPlaybackParametersChanged(playbackParameters) + logVideoSpeedEvent( + videoUrl, + playbackParameters.speed, + getCurrentVideoTime(), + getPlayingMedium() + ) } } @@ -139,7 +164,23 @@ class EncodedVideoUnitViewModel( DefaultBandwidthMeter.getSingletonInstance(context), DefaultAnalyticsCollector(Clock.DEFAULT) ).build() + logLoadedCompletedEvent(videoUrl, true, getCurrentVideoTime(), getPlayingMedium()) } private fun getVideoQuality() = preferencesManager.videoSettings.videoStreamingQuality + + override fun markBlockCompleted(blockId: String, medium: String) { + super.markBlockCompleted( + blockId, + getPlayingMedium() + ) + } + + private fun getPlayingMedium(): String { + return if (getActivePlayer() == castPlayer) { + CourseAnalyticsKey.GOOGLE_CAST.key + } else { + CourseAnalyticsKey.NATIVE.key + } + } } diff --git a/course/src/main/java/org/openedx/course/presentation/unit/video/VideoFullScreenFragment.kt b/course/src/main/java/org/openedx/course/presentation/unit/video/VideoFullScreenFragment.kt index 78133286e..c8a860cbc 100644 --- a/course/src/main/java/org/openedx/course/presentation/unit/video/VideoFullScreenFragment.kt +++ b/course/src/main/java/org/openedx/course/presentation/unit/video/VideoFullScreenFragment.kt @@ -9,13 +9,13 @@ import androidx.core.view.WindowInsetsCompat import androidx.fragment.app.Fragment import androidx.media3.common.C import androidx.media3.common.MediaItem +import androidx.media3.common.PlaybackParameters import androidx.media3.common.Player import androidx.media3.common.util.Clock import androidx.media3.datasource.DefaultDataSource import androidx.media3.exoplayer.DefaultLoadControl import androidx.media3.exoplayer.DefaultRenderersFactory import androidx.media3.exoplayer.ExoPlayer -import org.koin.android.ext.android.inject import androidx.media3.exoplayer.analytics.DefaultAnalyticsCollector import androidx.media3.exoplayer.hls.HlsMediaSource import androidx.media3.exoplayer.source.DefaultMediaSourceFactory @@ -23,6 +23,7 @@ import androidx.media3.exoplayer.trackselection.AdaptiveTrackSelection import androidx.media3.exoplayer.trackselection.DefaultTrackSelector import androidx.media3.exoplayer.upstream.DefaultBandwidthMeter import androidx.media3.extractor.DefaultExtractorsFactory +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.domain.model.VideoQuality @@ -31,6 +32,7 @@ import org.openedx.core.presentation.dialog.appreview.AppReviewManager import org.openedx.core.presentation.global.viewBinding import org.openedx.course.R import org.openedx.course.databinding.FragmentVideoFullScreenBinding +import org.openedx.course.presentation.CourseAnalyticsKey class VideoFullScreenFragment : Fragment(R.layout.fragment_video_full_screen) { @@ -54,7 +56,7 @@ class VideoFullScreenFragment : Fragment(R.layout.fragment_video_full_screen) { if (!appReviewManager.isDialogShowed) { appReviewManager.tryToOpenRateDialog() } - viewModel.markBlockCompleted(blockId) + viewModel.markBlockCompleted(blockId, CourseAnalyticsKey.NATIVE.key) } } } @@ -130,12 +132,32 @@ class VideoFullScreenFragment : Fragment(R.layout.fragment_video_full_screen) { } exoPlayer?.addListener(object : Player.Listener { + override fun onIsPlayingChanged(isPlaying: Boolean) { + super.onIsPlayingChanged(isPlaying) + viewModel.logPlayPauseEvent( + viewModel.videoUrl, + isPlaying, + viewModel.currentVideoTime, + CourseAnalyticsKey.NATIVE.key + ) + } + override fun onPlaybackStateChanged(playbackState: Int) { super.onPlaybackStateChanged(playbackState) if (playbackState == Player.STATE_ENDED) { - viewModel.markBlockCompleted(blockId) + viewModel.markBlockCompleted(blockId, CourseAnalyticsKey.NATIVE.key) } } + + override fun onPlaybackParametersChanged(playbackParameters: PlaybackParameters) { + super.onPlaybackParametersChanged(playbackParameters) + viewModel.logVideoSpeedEvent( + viewModel.videoUrl, + playbackParameters.speed, + viewModel.currentVideoTime, + CourseAnalyticsKey.NATIVE.key + ) + } }) } } @@ -144,7 +166,8 @@ class VideoFullScreenFragment : Fragment(R.layout.fragment_video_full_screen) { private fun setPlayerMedia(mediaItem: MediaItem) { if (viewModel.videoUrl.endsWith(".m3u8")) { val factory = DefaultDataSource.Factory(requireContext()) - val mediaSource: HlsMediaSource = HlsMediaSource.Factory(factory).createMediaSource(mediaItem) + val mediaSource: HlsMediaSource = + HlsMediaSource.Factory(factory).createMediaSource(mediaItem) exoPlayer?.setMediaSource(mediaSource, viewModel.currentVideoTime) } else { exoPlayer?.setMediaItem( @@ -196,7 +219,7 @@ class VideoFullScreenFragment : Fragment(R.layout.fragment_video_full_screen) { videoTime: Long, blockId: String, courseId: String, - isPlaying: Boolean + isPlaying: Boolean, ): VideoFullScreenFragment { val fragment = VideoFullScreenFragment() fragment.arguments = bundleOf( @@ -209,5 +232,4 @@ class VideoFullScreenFragment : Fragment(R.layout.fragment_video_full_screen) { return fragment } } - -} \ No newline at end of file +} diff --git a/course/src/main/java/org/openedx/course/presentation/unit/video/VideoUnitFragment.kt b/course/src/main/java/org/openedx/course/presentation/unit/video/VideoUnitFragment.kt index 4d231471c..2e078f4c6 100644 --- a/course/src/main/java/org/openedx/course/presentation/unit/video/VideoUnitFragment.kt +++ b/course/src/main/java/org/openedx/course/presentation/unit/video/VideoUnitFragment.kt @@ -41,6 +41,8 @@ import org.openedx.core.ui.theme.appColors import org.openedx.core.utils.LocaleUtils import org.openedx.course.R import org.openedx.course.databinding.FragmentVideoUnitBinding +import org.openedx.course.presentation.CourseAnalyticsEvent +import org.openedx.course.presentation.CourseAnalyticsKey import org.openedx.course.presentation.CourseRouter import org.openedx.course.presentation.ui.VideoSubtitles import org.openedx.course.presentation.ui.VideoTitle @@ -69,7 +71,7 @@ class VideoUnitFragment : Fragment(R.layout.fragment_video_unit) { } val completePercentage = it.currentPosition.toDouble() / it.duration.toDouble() if (completePercentage >= 0.8f) { - viewModel.markBlockCompleted(viewModel.blockId) + viewModel.markBlockCompleted(viewModel.blockId, CourseAnalyticsKey.NATIVE.key) } } handler.postDelayed(this, 200) @@ -89,6 +91,7 @@ class VideoUnitFragment : Fragment(R.layout.fragment_video_unit) { viewModel.isDownloaded = getBoolean(ARG_DOWNLOADED) } viewModel.downloadSubtitles() + handler.removeCallbacks(videoTimeRunnable) } override fun onViewCreated(view: View, savedInstanceState: Bundle?) { @@ -206,6 +209,7 @@ class VideoUnitFragment : Fragment(R.layout.fragment_video_unit) { viewModel.castPlayer?.setSessionAvailabilityListener( object : SessionAvailabilityListener { override fun onCastSessionAvailable() { + viewModel.logCastConnection(CourseAnalyticsEvent.CAST_CONNECTED) viewModel.isCastActive = true viewModel.exoPlayer?.pause() playerView.player = viewModel.castPlayer @@ -218,6 +222,7 @@ class VideoUnitFragment : Fragment(R.layout.fragment_video_unit) { } override fun onCastSessionUnavailable() { + viewModel.logCastConnection(CourseAnalyticsEvent.CAST_DISCONNECTED) viewModel.isCastActive = false playerView.player = viewModel.exoPlayer viewModel.exoPlayer?.seekTo(viewModel.castPlayer?.currentPosition ?: 0L) @@ -270,7 +275,8 @@ class VideoUnitFragment : Fragment(R.layout.fragment_video_unit) { private fun setPlayerMedia(mediaItem: MediaItem) { if (viewModel.videoUrl.endsWith(".m3u8")) { val factory = DefaultDataSource.Factory(requireContext()) - val mediaSource: HlsMediaSource = HlsMediaSource.Factory(factory).createMediaSource(mediaItem) + val mediaSource: HlsMediaSource = + HlsMediaSource.Factory(factory).createMediaSource(mediaItem) viewModel.exoPlayer?.setMediaSource(mediaSource, viewModel.getCurrentVideoTime()) } else { viewModel.getActivePlayer()?.setMediaItem( diff --git a/course/src/main/java/org/openedx/course/presentation/unit/video/VideoUnitViewModel.kt b/course/src/main/java/org/openedx/course/presentation/unit/video/VideoUnitViewModel.kt index 1005521a0..e28e723f6 100644 --- a/course/src/main/java/org/openedx/course/presentation/unit/video/VideoUnitViewModel.kt +++ b/course/src/main/java/org/openedx/course/presentation/unit/video/VideoUnitViewModel.kt @@ -9,7 +9,6 @@ import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.launch import org.openedx.core.AppDataConstants -import org.openedx.core.BaseViewModel import org.openedx.core.module.TranscriptManager import org.openedx.core.system.connection.NetworkConnection import org.openedx.core.system.notifier.CourseCompletionSet @@ -17,6 +16,7 @@ import org.openedx.core.system.notifier.CourseNotifier import org.openedx.core.system.notifier.CourseSubtitleLanguageChanged import org.openedx.core.system.notifier.CourseVideoPositionChanged import org.openedx.course.data.repository.CourseRepository +import org.openedx.course.presentation.CourseAnalytics import subtitleFile.TimedTextObject open class VideoUnitViewModel( @@ -25,7 +25,8 @@ open class VideoUnitViewModel( private val notifier: CourseNotifier, private val networkConnection: NetworkConnection, private val transcriptManager: TranscriptManager, -) : BaseViewModel() { + courseAnalytics: CourseAnalytics, +) : BaseVideoViewModel(courseId, courseAnalytics) { var videoUrl = "" var transcripts = emptyMap() @@ -98,7 +99,8 @@ open class VideoUnitViewModel( } - fun markBlockCompleted(blockId: String) { + open fun markBlockCompleted(blockId: String, medium: String) { + logLoadedCompletedEvent(videoUrl, false, getCurrentVideoTime(), medium) if (!isBlockAlreadyCompleted) { viewModelScope.launch { try { @@ -128,5 +130,4 @@ open class VideoUnitViewModel( } fun getCurrentVideoTime() = currentVideoTime.value ?: 0 - -} \ No newline at end of file +} diff --git a/course/src/main/java/org/openedx/course/presentation/unit/video/VideoViewModel.kt b/course/src/main/java/org/openedx/course/presentation/unit/video/VideoViewModel.kt index c5d1430a7..a4063393a 100644 --- a/course/src/main/java/org/openedx/course/presentation/unit/video/VideoViewModel.kt +++ b/course/src/main/java/org/openedx/course/presentation/unit/video/VideoViewModel.kt @@ -2,20 +2,21 @@ package org.openedx.course.presentation.unit.video import androidx.lifecycle.viewModelScope import androidx.media3.common.C -import org.openedx.core.BaseViewModel -import org.openedx.course.data.repository.CourseRepository -import org.openedx.core.system.notifier.CourseNotifier -import org.openedx.core.system.notifier.CourseVideoPositionChanged import kotlinx.coroutines.launch import org.openedx.core.data.storage.CorePreferences import org.openedx.core.system.notifier.CourseCompletionSet +import org.openedx.core.system.notifier.CourseNotifier +import org.openedx.core.system.notifier.CourseVideoPositionChanged +import org.openedx.course.data.repository.CourseRepository +import org.openedx.course.presentation.CourseAnalytics class VideoViewModel( private val courseId: String, private val courseRepository: CourseRepository, private val notifier: CourseNotifier, - private val preferencesManager: CorePreferences -) : BaseViewModel() { + private val preferencesManager: CorePreferences, + courseAnalytics: CourseAnalytics, +) : BaseVideoViewModel(courseId, courseAnalytics) { var videoUrl = "" var currentVideoTime = 0L @@ -27,12 +28,19 @@ class VideoViewModel( fun sendTime() { if (currentVideoTime != C.TIME_UNSET) { viewModelScope.launch { - notifier.send(CourseVideoPositionChanged(videoUrl, currentVideoTime, isPlaying ?: false)) + notifier.send( + CourseVideoPositionChanged( + videoUrl, + currentVideoTime, + isPlaying ?: false + ) + ) } } } - fun markBlockCompleted(blockId: String) { + fun markBlockCompleted(blockId: String, medium: String) { + logLoadedCompletedEvent(videoUrl, false, currentVideoTime, medium) if (!isBlockAlreadyCompleted) { viewModelScope.launch { try { diff --git a/course/src/main/java/org/openedx/course/presentation/unit/video/YoutubeVideoFullScreenFragment.kt b/course/src/main/java/org/openedx/course/presentation/unit/video/YoutubeVideoFullScreenFragment.kt index 0d6ce6d84..f62659c26 100644 --- a/course/src/main/java/org/openedx/course/presentation/unit/video/YoutubeVideoFullScreenFragment.kt +++ b/course/src/main/java/org/openedx/course/presentation/unit/video/YoutubeVideoFullScreenFragment.kt @@ -21,6 +21,7 @@ import org.openedx.core.presentation.dialog.appreview.AppReviewManager import org.openedx.core.presentation.global.viewBinding import org.openedx.course.R import org.openedx.course.databinding.FragmentYoutubeVideoFullScreenBinding +import org.openedx.course.presentation.CourseAnalyticsKey class YoutubeVideoFullScreenFragment : Fragment(R.layout.fragment_youtube_video_full_screen) { @@ -74,13 +75,13 @@ class YoutubeVideoFullScreenFragment : Fragment(R.layout.fragment_youtube_video_ override fun onStateChange( youTubePlayer: YouTubePlayer, - state: PlayerConstants.PlayerState + state: PlayerConstants.PlayerState, ) { super.onStateChange(youTubePlayer, state) if (state == PlayerConstants.PlayerState.ENDED) { - viewModel.markBlockCompleted(blockId) + viewModel.markBlockCompleted(blockId, CourseAnalyticsKey.YOUTUBE.key) } - viewModel.isPlaying = when(state) { + viewModel.isPlaying = when (state) { PlayerConstants.PlayerState.PLAYING -> true PlayerConstants.PlayerState.PAUSED -> false else -> return @@ -92,7 +93,7 @@ class YoutubeVideoFullScreenFragment : Fragment(R.layout.fragment_youtube_video_ viewModel.currentVideoTime = (second * 1000f).toLong() val completePercentage = second / youtubeTrackerListener.videoDuration if (completePercentage >= 0.8f && !isMarkBlockCompletedCalled) { - viewModel.markBlockCompleted(blockId) + viewModel.markBlockCompleted(blockId, CourseAnalyticsKey.YOUTUBE.key) isMarkBlockCompletedCalled = true } if (completePercentage >= 0.99f && !appReviewManager.isDialogShowed) { @@ -105,7 +106,8 @@ class YoutubeVideoFullScreenFragment : Fragment(R.layout.fragment_youtube_video_ override fun onReady(youTubePlayer: YouTubePlayer) { super.onReady(youTubePlayer) binding.youtubePlayerView.isVisible = true - val defPlayerUiController = DefaultPlayerUiController(binding.youtubePlayerView, youTubePlayer) + val defPlayerUiController = + DefaultPlayerUiController(binding.youtubePlayerView, youTubePlayer) defPlayerUiController.setFullScreenButtonClickListener { parentFragmentManager.popBackStack() } @@ -142,7 +144,7 @@ class YoutubeVideoFullScreenFragment : Fragment(R.layout.fragment_youtube_video_ videoTime: Long, blockId: String, courseId: String, - isPlaying: Boolean + isPlaying: Boolean, ): YoutubeVideoFullScreenFragment { val fragment = YoutubeVideoFullScreenFragment() fragment.arguments = bundleOf( @@ -156,4 +158,4 @@ class YoutubeVideoFullScreenFragment : Fragment(R.layout.fragment_youtube_video_ } } -} \ No newline at end of file +} diff --git a/course/src/main/java/org/openedx/course/presentation/unit/video/YoutubeVideoUnitFragment.kt b/course/src/main/java/org/openedx/course/presentation/unit/video/YoutubeVideoUnitFragment.kt index a842f4175..8ee99b970 100644 --- a/course/src/main/java/org/openedx/course/presentation/unit/video/YoutubeVideoUnitFragment.kt +++ b/course/src/main/java/org/openedx/course/presentation/unit/video/YoutubeVideoUnitFragment.kt @@ -36,6 +36,7 @@ import org.openedx.core.ui.theme.appColors import org.openedx.core.utils.LocaleUtils import org.openedx.course.R import org.openedx.course.databinding.FragmentYoutubeVideoUnitBinding +import org.openedx.course.presentation.CourseAnalyticsKey import org.openedx.course.presentation.CourseRouter import org.openedx.course.presentation.ui.VideoSubtitles import org.openedx.course.presentation.ui.VideoTitle @@ -77,7 +78,7 @@ class YoutubeVideoUnitFragment : Fragment(R.layout.fragment_youtube_video_unit) override fun onCreateView( inflater: LayoutInflater, container: ViewGroup?, - savedInstanceState: Bundle? + savedInstanceState: Bundle?, ): View { _binding = FragmentYoutubeVideoUnitBinding.inflate(inflater, container, false) return binding.root @@ -153,7 +154,7 @@ class YoutubeVideoUnitFragment : Fragment(R.layout.fragment_youtube_video_unit) viewModel.setCurrentVideoTime((second * 1000f).toLong()) val completePercentage = second / youtubeTrackerListener.videoDuration if (completePercentage >= 0.8f && !isMarkBlockCompletedCalled) { - viewModel.markBlockCompleted(blockId) + viewModel.markBlockCompleted(blockId, CourseAnalyticsKey.YOUTUBE.key) isMarkBlockCompletedCalled = true } if (completePercentage >= 0.99f && !appReviewManager.isDialogShowed) { @@ -163,7 +164,7 @@ class YoutubeVideoUnitFragment : Fragment(R.layout.fragment_youtube_video_unit) override fun onStateChange( youTubePlayer: YouTubePlayer, - state: PlayerConstants.PlayerState + state: PlayerConstants.PlayerState, ) { super.onStateChange(youTubePlayer, state) viewModel.isPlaying = when (state) { @@ -171,6 +172,12 @@ class YoutubeVideoUnitFragment : Fragment(R.layout.fragment_youtube_video_unit) PlayerConstants.PlayerState.PAUSED -> false else -> return } + viewModel.logPlayPauseEvent( + viewModel.videoUrl, + viewModel.isPlaying, + viewModel.getCurrentVideoTime(), + CourseAnalyticsKey.YOUTUBE.key + ) } override fun onReady(youTubePlayer: YouTubePlayer) { @@ -206,6 +213,12 @@ class YoutubeVideoUnitFragment : Fragment(R.layout.fragment_youtube_video_unit) } } youTubePlayer.addListener(youtubeTrackerListener) + viewModel.logLoadedCompletedEvent( + viewModel.videoUrl, + true, + viewModel.getCurrentVideoTime(), + CourseAnalyticsKey.YOUTUBE.key + ) } } @@ -240,7 +253,7 @@ class YoutubeVideoUnitFragment : Fragment(R.layout.fragment_youtube_video_unit) courseId: String, videoUrl: String, transcriptsUrl: Map, - blockTitle: String + blockTitle: String, ): YoutubeVideoUnitFragment { val fragment = YoutubeVideoUnitFragment() fragment.arguments = bundleOf( diff --git a/course/src/main/java/org/openedx/course/presentation/videos/CourseVideoViewModel.kt b/course/src/main/java/org/openedx/course/presentation/videos/CourseVideoViewModel.kt index 03190efce..5d0f3996d 100644 --- a/course/src/main/java/org/openedx/course/presentation/videos/CourseVideoViewModel.kt +++ b/course/src/main/java/org/openedx/course/presentation/videos/CourseVideoViewModel.kt @@ -16,6 +16,7 @@ import org.openedx.core.domain.model.VideoSettings import org.openedx.core.module.DownloadWorkerController import org.openedx.core.module.db.DownloadDao import org.openedx.core.module.download.BaseDownloadViewModel +import org.openedx.core.presentation.CoreAnalytics import org.openedx.core.system.ResourceManager import org.openedx.core.system.connection.NetworkConnection import org.openedx.core.system.notifier.CourseNotifier @@ -36,9 +37,16 @@ class CourseVideoViewModel( private val courseNotifier: CourseNotifier, private val videoNotifier: VideoNotifier, private val analytics: CourseAnalytics, + coreAnalytics: CoreAnalytics, downloadDao: DownloadDao, workerController: DownloadWorkerController -) : BaseDownloadViewModel(downloadDao, preferencesManager, workerController) { +) : BaseDownloadViewModel( + courseId, + downloadDao, + preferencesManager, + workerController, + coreAnalytics +) { val apiHostUrl get() = config.getApiHostURL() diff --git a/course/src/main/java/org/openedx/course/presentation/videos/CourseVideosFragment.kt b/course/src/main/java/org/openedx/course/presentation/videos/CourseVideosFragment.kt index 1d6f258f1..aa17ad783 100644 --- a/course/src/main/java/org/openedx/course/presentation/videos/CourseVideosFragment.kt +++ b/course/src/main/java/org/openedx/course/presentation/videos/CourseVideosFragment.kt @@ -112,6 +112,7 @@ class CourseVideosFragment : Fragment() { } }, onDownloadAllClick = { isAllBlocksDownloadedOrDownloading -> + viewModel.logBulkDownloadToggleEvent(!isAllBlocksDownloadedOrDownloading) if (isAllBlocksDownloadedOrDownloading) { viewModel.removeAllDownloadModels() } else { @@ -148,7 +149,7 @@ class CourseVideosFragment : Fragment() { private const val ARG_TITLE = "title" fun newInstance( courseId: String, - title: String + title: String, ): CourseVideosFragment { val fragment = CourseVideosFragment() fragment.arguments = bundleOf( diff --git a/course/src/main/java/org/openedx/course/settings/download/DownloadQueueViewModel.kt b/course/src/main/java/org/openedx/course/settings/download/DownloadQueueViewModel.kt index 9be72dd8c..3b9f3d1aa 100644 --- a/course/src/main/java/org/openedx/course/settings/download/DownloadQueueViewModel.kt +++ b/course/src/main/java/org/openedx/course/settings/download/DownloadQueueViewModel.kt @@ -8,6 +8,7 @@ import org.openedx.core.data.storage.CorePreferences import org.openedx.core.module.DownloadWorkerController import org.openedx.core.module.db.DownloadDao import org.openedx.core.module.download.BaseDownloadViewModel +import org.openedx.core.presentation.CoreAnalytics import org.openedx.core.system.notifier.DownloadNotifier import org.openedx.core.system.notifier.DownloadProgressChanged @@ -16,8 +17,9 @@ class DownloadQueueViewModel( downloadDao: DownloadDao, preferencesManager: CorePreferences, private val workerController: DownloadWorkerController, - private val downloadNotifier: DownloadNotifier -) : BaseDownloadViewModel(downloadDao, preferencesManager, workerController) { + private val downloadNotifier: DownloadNotifier, + coreAnalytics: CoreAnalytics, +) : BaseDownloadViewModel("", downloadDao, preferencesManager, workerController, coreAnalytics) { private val _uiState = MutableStateFlow(DownloadQueueUIState.Loading) val uiState = _uiState.asStateFlow() 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 a060753ab..0e94b5cc3 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 @@ -6,6 +6,7 @@ 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 @@ -120,6 +121,7 @@ class CourseContainerViewModelTest { @Test fun `preloadCourseStructure internet connection exception`() = runTest { val viewModel = CourseContainerViewModel( + "", "", "", config, @@ -128,16 +130,18 @@ class CourseContainerViewModelTest { resourceManager, notifier, networkConnection, - analytics, corePreferences, coursePreferences, + analytics, ) every { networkConnection.isOnline() } returns true coEvery { interactor.preloadCourseStructure(any()) } throws UnknownHostException() + every { analytics.logEvent(any(), any()) } returns Unit viewModel.preloadCourseStructure() advanceUntilIdle() coVerify(exactly = 1) { interactor.preloadCourseStructure(any()) } + verify(exactly = 1) { analytics.logEvent(any(), any()) } val message = viewModel.errorMessage.value assertEquals(noInternet, message) @@ -148,6 +152,7 @@ class CourseContainerViewModelTest { @Test fun `preloadCourseStructure unknown exception`() = runTest { val viewModel = CourseContainerViewModel( + "", "", "", config, @@ -156,16 +161,18 @@ class CourseContainerViewModelTest { resourceManager, notifier, networkConnection, - analytics, corePreferences, coursePreferences, + analytics, ) every { networkConnection.isOnline() } returns true coEvery { interactor.preloadCourseStructure(any()) } throws Exception() + every { analytics.logEvent(any(), any()) } returns Unit viewModel.preloadCourseStructure() advanceUntilIdle() coVerify(exactly = 1) { interactor.preloadCourseStructure(any()) } + verify(exactly = 1) { analytics.logEvent(any(), any()) } val message = viewModel.errorMessage.value assertEquals(somethingWrong, message) @@ -176,6 +183,7 @@ class CourseContainerViewModelTest { @Test fun `preloadCourseStructure success with internet`() = runTest { val viewModel = CourseContainerViewModel( + "", "", "", config, @@ -184,17 +192,19 @@ class CourseContainerViewModelTest { resourceManager, notifier, networkConnection, - analytics, corePreferences, coursePreferences, + analytics, ) every { networkConnection.isOnline() } returns true coEvery { interactor.preloadCourseStructure(any()) } returns Unit every { interactor.getCourseStructureFromCache() } returns courseStructure + every { analytics.logEvent(any(), any()) } returns Unit viewModel.preloadCourseStructure() advanceUntilIdle() coVerify(exactly = 1) { interactor.preloadCourseStructure(any()) } + verify(exactly = 1) { analytics.logEvent(any(), any()) } assert(viewModel.errorMessage.value == null) assert(viewModel.showProgress.value == false) @@ -204,6 +214,7 @@ class CourseContainerViewModelTest { @Test fun `preloadCourseStructure success without internet`() = runTest { val viewModel = CourseContainerViewModel( + "", "", "", config, @@ -212,18 +223,20 @@ class CourseContainerViewModelTest { resourceManager, notifier, networkConnection, - analytics, corePreferences, coursePreferences, + analytics, ) every { networkConnection.isOnline() } returns false coEvery { interactor.preloadCourseStructureFromCache(any()) } returns Unit every { interactor.getCourseStructureFromCache() } returns courseStructure + every { analytics.logEvent(any(), any()) } returns Unit viewModel.preloadCourseStructure() advanceUntilIdle() coVerify(exactly = 0) { interactor.preloadCourseStructure(any()) } coVerify(exactly = 1) { interactor.preloadCourseStructureFromCache(any()) } + verify(exactly = 1) { analytics.logEvent(any(), any()) } assert(viewModel.errorMessage.value == null) assert(viewModel.showProgress.value == false) @@ -233,6 +246,7 @@ class CourseContainerViewModelTest { @Test fun `updateData no internet connection exception`() = runTest { val viewModel = CourseContainerViewModel( + "", "", "", config, @@ -241,9 +255,9 @@ class CourseContainerViewModelTest { resourceManager, notifier, networkConnection, - analytics, corePreferences, coursePreferences, + analytics, ) coEvery { interactor.preloadCourseStructure(any()) } throws UnknownHostException() coEvery { notifier.send(CourseStructureUpdated("", false)) } returns Unit @@ -260,6 +274,7 @@ class CourseContainerViewModelTest { @Test fun `updateData unknown exception`() = runTest { val viewModel = CourseContainerViewModel( + "", "", "", config, @@ -268,9 +283,9 @@ class CourseContainerViewModelTest { resourceManager, notifier, networkConnection, - analytics, corePreferences, coursePreferences, + analytics, ) coEvery { interactor.preloadCourseStructure(any()) } throws Exception() coEvery { notifier.send(CourseStructureUpdated("", false)) } returns Unit @@ -287,6 +302,7 @@ class CourseContainerViewModelTest { @Test fun `updateData success`() = runTest { val viewModel = CourseContainerViewModel( + "", "", "", config, @@ -295,9 +311,9 @@ class CourseContainerViewModelTest { resourceManager, notifier, networkConnection, - analytics, corePreferences, coursePreferences, + analytics, ) coEvery { interactor.preloadCourseStructure(any()) } returns Unit coEvery { notifier.send(CourseStructureUpdated("", false)) } returns Unit 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 index 752ba30ba..82c3728e4 100644 --- a/course/src/test/java/org/openedx/course/presentation/dates/CourseDatesViewModelTest.kt +++ b/course/src/test/java/org/openedx/course/presentation/dates/CourseDatesViewModelTest.kt @@ -38,6 +38,7 @@ import org.openedx.core.system.connection.NetworkConnection import org.openedx.core.system.notifier.CalendarSyncEvent.CreateCalendarSyncEvent import org.openedx.core.system.notifier.CourseNotifier import org.openedx.course.domain.interactor.CourseInteractor +import org.openedx.course.presentation.CourseAnalytics import org.openedx.course.presentation.calendarsync.CalendarManager import java.net.UnknownHostException import java.util.Date @@ -55,6 +56,7 @@ class CourseDatesViewModelTest { private val calendarManager = mockk() private val networkConnection = mockk() private val corePreferences = mockk() + private val analytics = mockk() private val config = mockk() private val openEdx = "OpenEdx" @@ -156,12 +158,14 @@ class CourseDatesViewModelTest { "", "", true, + "", notifier, interactor, calendarManager, networkConnection, resourceManager, corePreferences, + analytics, config ) every { networkConnection.isOnline() } returns true @@ -183,12 +187,14 @@ class CourseDatesViewModelTest { "", "", true, + "", notifier, interactor, calendarManager, networkConnection, resourceManager, corePreferences, + analytics, config ) every { networkConnection.isOnline() } returns true @@ -210,12 +216,14 @@ class CourseDatesViewModelTest { "", "", true, + "", notifier, interactor, calendarManager, networkConnection, resourceManager, corePreferences, + analytics, config ) every { networkConnection.isOnline() } returns true @@ -236,12 +244,14 @@ class CourseDatesViewModelTest { "", "", true, + "", notifier, interactor, calendarManager, networkConnection, resourceManager, corePreferences, + analytics, config ) every { networkConnection.isOnline() } returns true 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 5f67f172c..458d7b7bf 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 @@ -208,15 +208,13 @@ class CourseDetailsViewModelTest { coEvery { notifier.send(CourseDashboardUpdate()) } returns Unit every { networkConnection.isOnline() } returns true coEvery { interactor.getCourseDetails(any()) } returns mockCourse - every { analytics.courseEnrollClickedEvent(any(), any()) } returns Unit - every { analytics.courseEnrollSuccessEvent(any(), any()) } returns Unit + every { analytics.logEvent(any(), any()) } returns Unit - viewModel.enrollInACourse("") + viewModel.enrollInACourse("", "") advanceUntilIdle() coVerify(exactly = 1) { interactor.enrollInACourse(any()) } - verify(exactly = 1) { analytics.courseEnrollClickedEvent(any(), any()) } - verify(exactly = 0) { analytics.courseEnrollSuccessEvent(any(), any()) } + verify { analytics.logEvent(any(), any()) } val message = viewModel.uiMessage.value as? UIMessage.SnackBarMessage assertEquals(noInternet, message?.message) @@ -241,16 +239,14 @@ class CourseDetailsViewModelTest { coEvery { notifier.send(CourseDashboardUpdate()) } returns Unit every { networkConnection.isOnline() } returns true coEvery { interactor.getCourseDetails(any()) } returns mockCourse - every { analytics.courseEnrollClickedEvent(any(), any()) } returns Unit - every { analytics.courseEnrollSuccessEvent(any(), any()) } returns Unit + every { analytics.logEvent(any(), any()) } returns Unit - viewModel.enrollInACourse("") + viewModel.enrollInACourse("", "") advanceUntilIdle() coVerify(exactly = 1) { interactor.enrollInACourse(any()) } - verify(exactly = 1) { analytics.courseEnrollClickedEvent(any(), any()) } - verify(exactly = 0) { analytics.courseEnrollSuccessEvent(any(), any()) } + verify(exactly = 1) { analytics.logEvent(any(), any()) } val message = viewModel.uiMessage.value as? UIMessage.SnackBarMessage assertEquals(somethingWrong, message?.message) @@ -271,8 +267,7 @@ class CourseDetailsViewModelTest { ) every { config.isPreLoginExperienceEnabled() } returns false every { preferencesManager.user } returns null - every { analytics.courseEnrollClickedEvent(any(), any()) } returns Unit - every { analytics.courseEnrollSuccessEvent(any(), any()) } returns Unit + every { analytics.logEvent(any(), any()) } returns Unit coEvery { interactor.enrollInACourse(any()) } returns Unit coEvery { notifier.send(CourseDashboardUpdate()) } returns Unit every { networkConnection.isOnline() } returns true @@ -280,12 +275,11 @@ class CourseDetailsViewModelTest { delay(200) - viewModel.enrollInACourse("") + viewModel.enrollInACourse("", "") advanceUntilIdle() coVerify(exactly = 1) { interactor.enrollInACourse(any()) } - verify(exactly = 1) { analytics.courseEnrollClickedEvent(any(), any()) } - verify(exactly = 1) { analytics.courseEnrollSuccessEvent(any(), any()) } + verify(exactly = 2) { analytics.logEvent(any(), any()) } assert(viewModel.uiMessage.value == null) assert(viewModel.uiState.value is CourseDetailsUIState.CourseData) 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 7c8ab38df..6e8d2dab2 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 @@ -5,6 +5,7 @@ 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.test.* @@ -17,6 +18,7 @@ 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 org.openedx.course.presentation.CourseAnalytics import java.net.UnknownHostException import java.util.* @@ -30,6 +32,7 @@ class HandoutsViewModelTest { private val config = mockk() private val interactor = mockk() + private val analytics = mockk() //region mockHandoutsModel @@ -50,17 +53,16 @@ class HandoutsViewModelTest { @Test fun `getEnrolledCourse no internet connection exception`() = runTest { - val viewModel = HandoutsViewModel("", config, "Handouts", interactor) + val viewModel = HandoutsViewModel("", "Handouts", config, interactor, analytics) coEvery { interactor.getHandouts(any()) } throws UnknownHostException() advanceUntilIdle() - assert(viewModel.htmlContent.value == null) } @Test fun `getEnrolledCourse unknown exception`() = runTest { - val viewModel = HandoutsViewModel("", config, "Handouts", interactor) + val viewModel = HandoutsViewModel("", "Handouts", config, interactor, analytics) coEvery { interactor.getHandouts(any()) } throws Exception() advanceUntilIdle() @@ -69,7 +71,8 @@ class HandoutsViewModelTest { @Test fun `getEnrolledCourse handouts success`() = runTest { - val viewModel = HandoutsViewModel("", config, HandoutsType.Handouts.name, interactor) + val viewModel = + HandoutsViewModel("", HandoutsType.Handouts.name, config, interactor, analytics) coEvery { interactor.getHandouts(any()) } returns HandoutsModel("hello") advanceUntilIdle() @@ -81,7 +84,8 @@ class HandoutsViewModelTest { @Test fun `getEnrolledCourse announcements success`() = runTest { - val viewModel = HandoutsViewModel("", config, HandoutsType.Announcements.name, interactor) + val viewModel = + HandoutsViewModel("", HandoutsType.Announcements.name, config, interactor, analytics) coEvery { interactor.getAnnouncements(any()) } returns listOf( AnnouncementModel( "date", @@ -98,7 +102,8 @@ class HandoutsViewModelTest { @Test fun `injectDarkMode test`() = runTest { - val viewModel = HandoutsViewModel("", config, HandoutsType.Announcements.name, interactor) + val viewModel = + HandoutsViewModel("", HandoutsType.Announcements.name, config, interactor, analytics) coEvery { interactor.getAnnouncements(any()) } returns listOf( AnnouncementModel( "date", @@ -116,5 +121,4 @@ class HandoutsViewModelTest { assert(viewModel.htmlContent.value != null) } - -} \ No newline at end of file +} 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 6683b9f3d..e72101887 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 @@ -46,6 +46,7 @@ import org.openedx.core.module.db.DownloadModel import org.openedx.core.module.db.DownloadModelEntity import org.openedx.core.module.db.DownloadedState import org.openedx.core.module.db.FileType +import org.openedx.core.presentation.CoreAnalytics import org.openedx.core.system.ResourceManager import org.openedx.core.system.connection.NetworkConnection import org.openedx.core.system.notifier.CourseNotifier @@ -72,6 +73,7 @@ class CourseOutlineViewModelTest { private val downloadDao = mockk() private val workerController = mockk() private val analytics = mockk() + private val coreAnalytics = mockk() private val noInternet = "Slow or no internet connection" private val somethingWrong = "Something went wrong" @@ -226,8 +228,9 @@ class CourseOutlineViewModelTest { networkConnection, preferencesManager, analytics, + coreAnalytics, downloadDao, - workerController + workerController, ) advanceUntilIdle() @@ -255,6 +258,7 @@ class CourseOutlineViewModelTest { networkConnection, preferencesManager, analytics, + coreAnalytics, downloadDao, workerController ) @@ -295,6 +299,7 @@ class CourseOutlineViewModelTest { networkConnection, preferencesManager, analytics, + coreAnalytics, downloadDao, workerController ) @@ -334,6 +339,7 @@ class CourseOutlineViewModelTest { networkConnection, preferencesManager, analytics, + coreAnalytics, downloadDao, workerController ) @@ -373,6 +379,7 @@ class CourseOutlineViewModelTest { networkConnection, preferencesManager, analytics, + coreAnalytics, downloadDao, workerController ) @@ -399,6 +406,7 @@ class CourseOutlineViewModelTest { networkConnection, preferencesManager, analytics, + coreAnalytics, downloadDao, workerController ) @@ -431,6 +439,7 @@ class CourseOutlineViewModelTest { every { interactor.getCourseStructureFromCache() } returns courseStructure every { networkConnection.isWifiConnected() } returns true every { networkConnection.isOnline() } returns true + every { coreAnalytics.logEvent(any(), any()) } returns Unit coEvery { workerController.saveModels(any()) } returns Unit coEvery { interactor.getCourseStatus(any()) } returns CourseComponentStatus("id") coEvery { downloadDao.readAllData() } returns flow { emit(emptyList()) } @@ -445,12 +454,14 @@ class CourseOutlineViewModelTest { networkConnection, preferencesManager, analytics, + coreAnalytics, downloadDao, workerController ) viewModel.saveDownloadModels("", "") advanceUntilIdle() + verify(exactly = 1) { coreAnalytics.logEvent(any(), any()) } assert(viewModel.uiMessage.value == null) } @@ -466,6 +477,7 @@ class CourseOutlineViewModelTest { coEvery { workerController.saveModels(any()) } returns Unit coEvery { downloadDao.readAllData() } returns flow { emit(emptyList()) } every { config.isCourseNestedListEnabled() } returns false + every { coreAnalytics.logEvent(any(), any()) } returns Unit val viewModel = CourseOutlineViewModel( "", @@ -476,6 +488,7 @@ class CourseOutlineViewModelTest { networkConnection, preferencesManager, analytics, + coreAnalytics, downloadDao, workerController ) @@ -505,6 +518,7 @@ class CourseOutlineViewModelTest { networkConnection, preferencesManager, analytics, + coreAnalytics, downloadDao, workerController ) 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 d2f8a0b6b..6106792f5 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 @@ -4,21 +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.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.module.DownloadWorkerController -import org.openedx.core.module.db.* -import org.openedx.core.presentation.course.CourseViewMode -import org.openedx.core.system.ResourceManager -import org.openedx.core.system.connection.NetworkConnection -import org.openedx.core.system.notifier.CourseNotifier -import org.openedx.course.domain.interactor.CourseInteractor -import org.openedx.course.presentation.CourseAnalytics import io.mockk.coEvery import io.mockk.coVerify import io.mockk.every @@ -27,16 +12,40 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.delay import kotlinx.coroutines.flow.flow -import kotlinx.coroutines.test.* +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.assertEquals 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.data.storage.CorePreferences +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.module.DownloadWorkerController +import org.openedx.core.module.db.DownloadDao +import org.openedx.core.module.db.DownloadModel +import org.openedx.core.module.db.DownloadModelEntity +import org.openedx.core.module.db.DownloadedState +import org.openedx.core.module.db.FileType +import org.openedx.core.presentation.CoreAnalytics +import org.openedx.core.presentation.course.CourseViewMode +import org.openedx.core.system.ResourceManager +import org.openedx.core.system.connection.NetworkConnection +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 CourseSectionViewModelTest { @@ -54,6 +63,7 @@ class CourseSectionViewModelTest { private val preferencesManager = mockk() private val notifier = mockk() private val analytics = mockk() + private val coreAnalytics = mockk() private val noInternet = "Slow or no internet connection" private val somethingWrong = "Something went wrong" @@ -162,15 +172,16 @@ class CourseSectionViewModelTest { @Test fun `getBlocks no internet connection exception`() = runTest { val viewModel = CourseSectionViewModel( + "", interactor, resourceManager, networkConnection, preferencesManager, notifier, analytics, + coreAnalytics, workerController, downloadDao, - "" ) coEvery { interactor.getCourseStructureFromCache() } throws UnknownHostException() @@ -190,15 +201,16 @@ class CourseSectionViewModelTest { @Test fun `getBlocks unknown exception`() = runTest { val viewModel = CourseSectionViewModel( + "", interactor, resourceManager, networkConnection, preferencesManager, notifier, analytics, + coreAnalytics, workerController, downloadDao, - "" ) coEvery { interactor.getCourseStructureFromCache() } throws Exception() @@ -218,15 +230,16 @@ class CourseSectionViewModelTest { @Test fun `getBlocks success`() = runTest { val viewModel = CourseSectionViewModel( + "", interactor, resourceManager, networkConnection, preferencesManager, notifier, analytics, + coreAnalytics, workerController, downloadDao, - "" ) coEvery { downloadDao.readAllData() } returns flow { @@ -248,15 +261,16 @@ class CourseSectionViewModelTest { @Test fun `saveDownloadModels test`() = runTest { val viewModel = CourseSectionViewModel( + "", interactor, resourceManager, networkConnection, preferencesManager, notifier, analytics, + coreAnalytics, workerController, downloadDao, - "" ) every { preferencesManager.videoSettings.wifiDownloadOnly } returns false every { networkConnection.isWifiConnected() } returns true @@ -264,6 +278,7 @@ class CourseSectionViewModelTest { coEvery { downloadDao.readAllData() } returns flow { emit(listOf(DownloadModelEntity.createFrom(downloadModel))) } + every { coreAnalytics.logEvent(any(), any()) } returns Unit viewModel.saveDownloadModels("", "") advanceUntilIdle() @@ -274,15 +289,16 @@ class CourseSectionViewModelTest { @Test fun `saveDownloadModels only wifi download, with connection`() = runTest { val viewModel = CourseSectionViewModel( + "", interactor, resourceManager, networkConnection, preferencesManager, notifier, analytics, + coreAnalytics, workerController, downloadDao, - "" ) every { preferencesManager.videoSettings.wifiDownloadOnly } returns true every { networkConnection.isWifiConnected() } returns true @@ -290,6 +306,7 @@ class CourseSectionViewModelTest { coEvery { downloadDao.readAllData() } returns flow { emit(listOf(DownloadModelEntity.createFrom(downloadModel))) } + every { coreAnalytics.logEvent(any(), any()) } returns Unit viewModel.saveDownloadModels("", "") advanceUntilIdle() @@ -300,15 +317,16 @@ class CourseSectionViewModelTest { @Test fun `saveDownloadModels only wifi download, without connection`() = runTest { val viewModel = CourseSectionViewModel( + "", interactor, resourceManager, networkConnection, preferencesManager, notifier, analytics, + coreAnalytics, workerController, downloadDao, - "" ) every { preferencesManager.videoSettings.wifiDownloadOnly } returns true every { networkConnection.isWifiConnected() } returns false @@ -326,15 +344,16 @@ class CourseSectionViewModelTest { @Test fun `updateVideos success`() = runTest { val viewModel = CourseSectionViewModel( + "", interactor, resourceManager, networkConnection, preferencesManager, notifier, analytics, + coreAnalytics, workerController, downloadDao, - "" ) every { downloadDao.readAllData() } returns flow { 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 c087a3158..b92a02f5a 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 @@ -20,7 +20,6 @@ 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 @@ -145,7 +144,8 @@ class CourseUnitContainerViewModelTest { @Test fun `getBlocks no internet connection exception`() = runTest { every { notifier.notifier } returns MutableSharedFlow() - val viewModel = CourseUnitContainerViewModel(config, interactor, notifier, analytics, "") + val viewModel = + CourseUnitContainerViewModel("", "", config, interactor, notifier, analytics) every { interactor.getCourseStructureFromCache() } throws UnknownHostException() every { interactor.getCourseStructureForVideos() } throws UnknownHostException() @@ -159,7 +159,7 @@ class CourseUnitContainerViewModelTest { @Test fun `getBlocks unknown exception`() = runTest { every { notifier.notifier } returns MutableSharedFlow() - val viewModel = CourseUnitContainerViewModel(config, interactor, notifier, analytics, "") + val viewModel = CourseUnitContainerViewModel("", "", config, interactor, notifier, analytics) every { interactor.getCourseStructureFromCache() } throws UnknownHostException() every { interactor.getCourseStructureForVideos() } throws UnknownHostException() @@ -173,7 +173,7 @@ class CourseUnitContainerViewModelTest { @Test fun `getBlocks unknown success`() = runTest { every { notifier.notifier } returns MutableSharedFlow() - val viewModel = CourseUnitContainerViewModel(config, interactor, notifier, analytics, "") + val viewModel = CourseUnitContainerViewModel("", "", config, interactor, notifier, analytics) every { interactor.getCourseStructureFromCache() } returns courseStructure every { interactor.getCourseStructureForVideos() } returns courseStructure @@ -189,7 +189,7 @@ class CourseUnitContainerViewModelTest { @Test fun setupCurrentIndex() = runTest { every { notifier.notifier } returns MutableSharedFlow() - val viewModel = CourseUnitContainerViewModel(config, interactor, notifier, analytics, "") + val viewModel = CourseUnitContainerViewModel("", "", config, interactor, notifier, analytics) every { interactor.getCourseStructureFromCache() } returns courseStructure every { interactor.getCourseStructureForVideos() } returns courseStructure @@ -204,7 +204,7 @@ class CourseUnitContainerViewModelTest { @Test fun `getCurrentBlock test`() = runTest { every { notifier.notifier } returns MutableSharedFlow() - val viewModel = CourseUnitContainerViewModel(config, interactor, notifier, analytics, "") + val viewModel = CourseUnitContainerViewModel("", "", config, interactor, notifier, analytics) every { interactor.getCourseStructureFromCache() } returns courseStructure every { interactor.getCourseStructureForVideos() } returns courseStructure @@ -221,7 +221,7 @@ class CourseUnitContainerViewModelTest { @Test fun `moveToPrevBlock null`() = runTest { every { notifier.notifier } returns MutableSharedFlow() - val viewModel = CourseUnitContainerViewModel(config, interactor, notifier, analytics, "") + val viewModel = CourseUnitContainerViewModel("", "", config, interactor, notifier, analytics) every { interactor.getCourseStructureFromCache() } returns courseStructure every { interactor.getCourseStructureForVideos() } returns courseStructure @@ -238,12 +238,12 @@ class CourseUnitContainerViewModelTest { @Test fun `moveToPrevBlock not null`() = runTest { every { notifier.notifier } returns MutableSharedFlow() - val viewModel = CourseUnitContainerViewModel(config, interactor, notifier, analytics, "") + val viewModel = CourseUnitContainerViewModel("", "id", config, interactor, notifier, analytics) every { interactor.getCourseStructureFromCache() } returns courseStructure every { interactor.getCourseStructureForVideos() } returns courseStructure viewModel.loadBlocks(CourseViewMode.VIDEOS) - viewModel.setupCurrentIndex("id1", "id") + viewModel.setupCurrentIndex("id1") advanceUntilIdle() @@ -255,7 +255,7 @@ class CourseUnitContainerViewModelTest { @Test fun `moveToNextBlock null`() = runTest { every { notifier.notifier } returns MutableSharedFlow() - val viewModel = CourseUnitContainerViewModel(config, interactor, notifier, analytics, "") + val viewModel = CourseUnitContainerViewModel("", "", config, interactor, notifier, analytics) every { interactor.getCourseStructureFromCache() } returns courseStructure every { interactor.getCourseStructureForVideos() } returns courseStructure @@ -272,7 +272,7 @@ class CourseUnitContainerViewModelTest { @Test fun `moveToNextBlock not null`() = runTest { every { notifier.notifier } returns MutableSharedFlow() - val viewModel = CourseUnitContainerViewModel(config, interactor, notifier, analytics, "") + val viewModel = CourseUnitContainerViewModel("", "id", config, interactor, notifier, analytics) every { interactor.getCourseStructureFromCache() } returns courseStructure every { interactor.getCourseStructureForVideos() } returns courseStructure @@ -289,7 +289,7 @@ class CourseUnitContainerViewModelTest { @Test fun `currentIndex isLastIndex`() = runTest { every { notifier.notifier } returns MutableSharedFlow() - val viewModel = CourseUnitContainerViewModel(config, interactor, notifier, analytics, "") + 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 a77b6ae38..498094d03 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 @@ -4,14 +4,11 @@ import androidx.arch.core.executor.testing.InstantTaskExecutorRule import androidx.lifecycle.Lifecycle import androidx.lifecycle.LifecycleOwner import androidx.lifecycle.LifecycleRegistry -import org.openedx.core.module.TranscriptManager -import org.openedx.core.system.connection.NetworkConnection -import org.openedx.core.system.notifier.CourseNotifier -import org.openedx.core.system.notifier.CourseVideoPositionChanged -import org.openedx.course.data.repository.CourseRepository 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.flow @@ -21,6 +18,12 @@ import org.junit.Before import org.junit.Rule import org.junit.Test import org.junit.rules.TestRule +import org.openedx.core.module.TranscriptManager +import org.openedx.core.system.connection.NetworkConnection +import org.openedx.core.system.notifier.CourseNotifier +import org.openedx.core.system.notifier.CourseVideoPositionChanged +import org.openedx.course.data.repository.CourseRepository +import org.openedx.course.presentation.CourseAnalytics @OptIn(ExperimentalCoroutinesApi::class) class VideoUnitViewModelTest { @@ -34,6 +37,7 @@ class VideoUnitViewModelTest { private val notifier = mockk() private val networkConnection = mockk() private val transcriptManager = mockk() + private val courseAnalytics = mockk() @Before @@ -53,7 +57,8 @@ class VideoUnitViewModelTest { courseRepository, notifier, networkConnection, - transcriptManager + transcriptManager, + courseAnalytics ) coEvery { courseRepository.markBlocksCompletion( @@ -61,7 +66,8 @@ class VideoUnitViewModelTest { any() ) } throws Exception() - viewModel.markBlockCompleted("") + every { courseAnalytics.logEvent(any(), any()) } returns Unit + viewModel.markBlockCompleted("", "") advanceUntilIdle() coVerify(exactly = 1) { @@ -70,6 +76,7 @@ class VideoUnitViewModelTest { any() ) } + verify(exactly = 1) { courseAnalytics.logEvent(any(), any()) } } @Test @@ -79,7 +86,8 @@ class VideoUnitViewModelTest { courseRepository, notifier, networkConnection, - transcriptManager + transcriptManager, + courseAnalytics, ) coEvery { courseRepository.markBlocksCompletion( @@ -87,7 +95,8 @@ class VideoUnitViewModelTest { any() ) } returns Unit - viewModel.markBlockCompleted("") + every { courseAnalytics.logEvent(any(), any()) } returns Unit + viewModel.markBlockCompleted("", "") advanceUntilIdle() coVerify(exactly = 1) { @@ -96,6 +105,7 @@ class VideoUnitViewModelTest { any() ) } + verify(exactly = 1) { courseAnalytics.logEvent(any(), any()) } } @Test @@ -105,9 +115,18 @@ class VideoUnitViewModelTest { courseRepository, notifier, networkConnection, - transcriptManager + transcriptManager, + courseAnalytics, ) - coEvery { notifier.notifier } returns flow { emit(CourseVideoPositionChanged("", 10, false)) } + 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 ce1799432..278e24380 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 @@ -6,7 +6,9 @@ import org.openedx.core.system.notifier.CourseVideoPositionChanged import org.openedx.course.data.repository.CourseRepository 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.test.* @@ -16,6 +18,7 @@ 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 @OptIn(ExperimentalCoroutinesApi::class) class VideoViewModelTest { @@ -28,6 +31,7 @@ class VideoViewModelTest { private val courseRepository = mockk() private val notifier = mockk() private val preferenceManager = mockk() + private val courseAnalytics = mockk() @Before fun setUp() { @@ -41,7 +45,7 @@ class VideoViewModelTest { @Test fun `sendTime test`() = runTest { - val viewModel = VideoViewModel("", courseRepository, notifier, preferenceManager) + val viewModel = VideoViewModel("", courseRepository, notifier, preferenceManager, courseAnalytics) coEvery { notifier.send(CourseVideoPositionChanged("", 0, false)) } returns Unit viewModel.sendTime() advanceUntilIdle() @@ -51,14 +55,15 @@ class VideoViewModelTest { @Test fun `markBlockCompleted exception`() = runTest { - val viewModel = VideoViewModel("", courseRepository, notifier, preferenceManager) + val viewModel = VideoViewModel("", courseRepository, notifier, preferenceManager, courseAnalytics) coEvery { courseRepository.markBlocksCompletion( any(), any() ) } throws Exception() - viewModel.markBlockCompleted("") + every { courseAnalytics.logEvent(any(), any()) } returns Unit + viewModel.markBlockCompleted("", "") advanceUntilIdle() coVerify(exactly = 1) { @@ -67,18 +72,21 @@ class VideoViewModelTest { any() ) } + verify(exactly = 1) { courseAnalytics.logEvent(any(), any()) } + } @Test fun `markBlockCompleted success`() = runTest { - val viewModel = VideoViewModel("", courseRepository, notifier, preferenceManager) + val viewModel = VideoViewModel("", courseRepository, notifier, preferenceManager, courseAnalytics) coEvery { courseRepository.markBlocksCompletion( any(), any() ) } returns Unit - viewModel.markBlockCompleted("") + every { courseAnalytics.logEvent(any(), any()) } returns Unit + viewModel.markBlockCompleted("", "") advanceUntilIdle() coVerify(exactly = 1) { @@ -87,6 +95,7 @@ class VideoViewModelTest { any() ) } + verify(exactly = 1) { courseAnalytics.logEvent(any(), any()) } } } \ No newline at end of file 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 d898c5f4b..a825345cf 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 @@ -37,6 +37,7 @@ import org.openedx.core.module.db.DownloadModel import org.openedx.core.module.db.DownloadModelEntity import org.openedx.core.module.db.DownloadedState import org.openedx.core.module.db.FileType +import org.openedx.core.presentation.CoreAnalytics import org.openedx.core.system.ResourceManager import org.openedx.core.system.connection.NetworkConnection import org.openedx.core.system.notifier.CourseNotifier @@ -60,6 +61,7 @@ class CourseVideoViewModelTest { private val courseNotifier = spyk() private val videoNotifier = spyk() private val analytics = mockk() + private val coreAnalytics = mockk() private val preferencesManager = mockk() private val networkConnection = mockk() private val downloadDao = mockk() @@ -185,6 +187,7 @@ class CourseVideoViewModelTest { courseNotifier, videoNotifier, analytics, + coreAnalytics, downloadDao, workerController ) @@ -214,6 +217,7 @@ class CourseVideoViewModelTest { courseNotifier, videoNotifier, analytics, + coreAnalytics, downloadDao, workerController ) @@ -249,6 +253,7 @@ class CourseVideoViewModelTest { courseNotifier, videoNotifier, analytics, + coreAnalytics, downloadDao, workerController ) @@ -280,6 +285,7 @@ class CourseVideoViewModelTest { courseNotifier, videoNotifier, analytics, + coreAnalytics, downloadDao, workerController ) @@ -305,6 +311,7 @@ class CourseVideoViewModelTest { courseNotifier, videoNotifier, analytics, + coreAnalytics, downloadDao, workerController ) @@ -313,6 +320,7 @@ class CourseVideoViewModelTest { every { preferencesManager.videoSettings.wifiDownloadOnly } returns false every { networkConnection.isWifiConnected() } returns true coEvery { workerController.saveModels(any()) } returns Unit + every { coreAnalytics.logEvent(any(), any()) } returns Unit viewModel.saveDownloadModels("", "") advanceUntilIdle() @@ -334,6 +342,7 @@ class CourseVideoViewModelTest { courseNotifier, videoNotifier, analytics, + coreAnalytics, downloadDao, workerController ) @@ -345,6 +354,7 @@ class CourseVideoViewModelTest { coEvery { downloadDao.readAllData() } returns flow { emit(listOf(DownloadModelEntity.createFrom(downloadModel))) } + every { coreAnalytics.logEvent(any(), any()) } returns Unit viewModel.saveDownloadModels("", "") advanceUntilIdle() @@ -366,6 +376,7 @@ class CourseVideoViewModelTest { courseNotifier, videoNotifier, analytics, + coreAnalytics, downloadDao, workerController ) 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 83ba221e2..cca5edecd 100644 --- a/dashboard/src/main/java/org/openedx/dashboard/presentation/DashboardRouter.kt +++ b/dashboard/src/main/java/org/openedx/dashboard/presentation/DashboardRouter.kt @@ -7,7 +7,8 @@ interface DashboardRouter { fun navigateToCourseOutline( fm: FragmentManager, courseId: String, - courseTitle: String + courseTitle: String, + enrollmentMode: String, ) fun navigateToProgramInfo( diff --git a/dashboard/src/main/java/org/openedx/dashboard/presentation/dashboard/DashboardFragment.kt b/dashboard/src/main/java/org/openedx/dashboard/presentation/dashboard/DashboardFragment.kt index 85cd83d5d..2b0e17d72 100644 --- a/dashboard/src/main/java/org/openedx/dashboard/presentation/dashboard/DashboardFragment.kt +++ b/dashboard/src/main/java/org/openedx/dashboard/presentation/dashboard/DashboardFragment.kt @@ -139,7 +139,8 @@ class DashboardFragment : Fragment() { router.navigateToCourseOutline( requireParentFragment().parentFragmentManager, it.course.id, - it.course.name + it.course.name, + it.mode ) }, onSwipeRefresh = { 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 index 08dedc32e..bb06cabc1 100644 --- a/dashboard/src/main/java/org/openedx/dashboard/presentation/program/ProgramFragment.kt +++ b/dashboard/src/main/java/org/openedx/dashboard/presentation/program/ProgramFragment.kt @@ -43,6 +43,7 @@ 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.CoreAnalyticsScreen import org.openedx.core.presentation.catalog.CatalogWebViewScreen import org.openedx.core.presentation.catalog.WebViewLink import org.openedx.core.presentation.dialog.alert.ActionDialogFragment @@ -170,6 +171,7 @@ class ProgramFragment(private val myPrograms: Boolean = false) : Fragment() { getString(coreR.string.platform_name) ), url = param, + source = CoreAnalyticsScreen.PROGRAM.screenName ).show( requireActivity().supportFragmentManager, ActionDialogFragment::class.simpleName 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 index da5453fdf..28de4a3d9 100644 --- a/dashboard/src/main/java/org/openedx/dashboard/presentation/program/ProgramViewModel.kt +++ b/dashboard/src/main/java/org/openedx/dashboard/presentation/program/ProgramViewModel.kt @@ -90,7 +90,8 @@ class ProgramViewModel( router.navigateToCourseOutline( fm = fragmentManager, courseId = courseId, - courseTitle = "" + courseTitle = "", + enrollmentMode = "" ) } } diff --git a/discovery/src/main/java/org/openedx/discovery/presentation/DiscoveryAnalytics.kt b/discovery/src/main/java/org/openedx/discovery/presentation/DiscoveryAnalytics.kt index 93a5086b2..1cf7c1928 100644 --- a/discovery/src/main/java/org/openedx/discovery/presentation/DiscoveryAnalytics.kt +++ b/discovery/src/main/java/org/openedx/discovery/presentation/DiscoveryAnalytics.kt @@ -4,4 +4,24 @@ interface DiscoveryAnalytics { fun discoverySearchBarClickedEvent() fun discoveryCourseSearchEvent(label: String, coursesCount: Int) fun discoveryCourseClickedEvent(courseId: String, courseName: String) -} \ No newline at end of file + fun logEvent(event: String, params: Map) +} + +enum class DiscoveryAnalyticsEvent(val eventName: String, val biValue: String) { + COURSE_INFO( + "Discovery:Course Info", + "edx.bi.app.discovery.course_info" + ), + PROGRAM_INFO( + "Discovery:Program Info", + "edx.bi.app.discovery.program_info" + ), +} + +enum class DiscoveryAnalyticsKey(val key: String) { + NAME("name"), + COURSE_ID("course_id"), + COURSE_NAME("course_name"), + CATEGORY("category"), + DISCOVERY("discovery"), +} diff --git a/discovery/src/main/java/org/openedx/discovery/presentation/NativeDiscoveryFragment.kt b/discovery/src/main/java/org/openedx/discovery/presentation/NativeDiscoveryFragment.kt index 6db42a074..08eebc83a 100644 --- a/discovery/src/main/java/org/openedx/discovery/presentation/NativeDiscoveryFragment.kt +++ b/discovery/src/main/java/org/openedx/discovery/presentation/NativeDiscoveryFragment.kt @@ -151,6 +151,7 @@ class NativeDiscoveryFragment : Fragment() { }, onItemClick = { course -> viewModel.discoveryCourseClicked(course.id, course.name) + viewModel.courseDetailClickedEvent(course.id, course.name) router.navigateToCourseDetail( requireActivity().supportFragmentManager, course.id diff --git a/discovery/src/main/java/org/openedx/discovery/presentation/NativeDiscoveryViewModel.kt b/discovery/src/main/java/org/openedx/discovery/presentation/NativeDiscoveryViewModel.kt index 0a2d5603c..06132aab6 100644 --- a/discovery/src/main/java/org/openedx/discovery/presentation/NativeDiscoveryViewModel.kt +++ b/discovery/src/main/java/org/openedx/discovery/presentation/NativeDiscoveryViewModel.kt @@ -27,7 +27,7 @@ class NativeDiscoveryViewModel( private val resourceManager: ResourceManager, private val analytics: DiscoveryAnalytics, private val appUpgradeNotifier: AppUpgradeNotifier, - private val corePreferences: CorePreferences + private val corePreferences: CorePreferences, ) : BaseViewModel() { val apiHostUrl get() = config.getApiHostURL() @@ -183,4 +183,17 @@ class NativeDiscoveryViewModel( fun discoveryCourseClicked(courseId: String, courseName: String) { analytics.discoveryCourseClickedEvent(courseId, courseName) } + + fun courseDetailClickedEvent(courseId: String, courseTitle: String) { + val event = DiscoveryAnalyticsEvent.COURSE_INFO + analytics.logEvent( + event.eventName, + buildMap { + put(DiscoveryAnalyticsKey.NAME.key, event.biValue) + put(DiscoveryAnalyticsKey.COURSE_ID.key, courseId) + put(DiscoveryAnalyticsKey.COURSE_NAME.key, courseTitle) + put(DiscoveryAnalyticsKey.CATEGORY.key, DiscoveryAnalyticsKey.DISCOVERY.key) + } + ) + } } diff --git a/discovery/src/main/java/org/openedx/discovery/presentation/WebViewDiscoveryFragment.kt b/discovery/src/main/java/org/openedx/discovery/presentation/WebViewDiscoveryFragment.kt index f03e34d58..559998a19 100644 --- a/discovery/src/main/java/org/openedx/discovery/presentation/WebViewDiscoveryFragment.kt +++ b/discovery/src/main/java/org/openedx/discovery/presentation/WebViewDiscoveryFragment.kt @@ -50,6 +50,7 @@ import androidx.lifecycle.LifecycleEventObserver import androidx.lifecycle.LifecycleOwner import org.koin.androidx.viewmodel.ext.android.viewModel import org.koin.core.parameter.parametersOf +import org.openedx.core.presentation.CoreAnalyticsScreen import org.openedx.core.presentation.catalog.CatalogWebViewScreen import org.openedx.core.presentation.catalog.WebViewLink import org.openedx.core.presentation.dialog.alert.ActionDialogFragment @@ -99,8 +100,17 @@ class WebViewDiscoveryFragment : Fragment() { }, onUriClick = { param, authority -> when (authority) { - WebViewLink.Authority.COURSE_INFO, + WebViewLink.Authority.COURSE_INFO -> { + viewModel.courseInfoClickedEvent(param) + viewModel.infoCardClicked( + fragmentManager = requireActivity().supportFragmentManager, + pathId = param, + infoType = authority.name + ) + } + WebViewLink.Authority.PROGRAM_INFO -> { + viewModel.programInfoClickedEvent(param) viewModel.infoCardClicked( fragmentManager = requireActivity().supportFragmentManager, pathId = param, @@ -116,6 +126,7 @@ class WebViewDiscoveryFragment : Fragment() { getString(CoreR.string.platform_name) ), url = param, + source = CoreAnalyticsScreen.DISCOVERY.screenName ).show( requireActivity().supportFragmentManager, ActionDialogFragment::class.simpleName diff --git a/discovery/src/main/java/org/openedx/discovery/presentation/WebViewDiscoveryViewModel.kt b/discovery/src/main/java/org/openedx/discovery/presentation/WebViewDiscoveryViewModel.kt index 7fa72c67f..2fbfffb07 100644 --- a/discovery/src/main/java/org/openedx/discovery/presentation/WebViewDiscoveryViewModel.kt +++ b/discovery/src/main/java/org/openedx/discovery/presentation/WebViewDiscoveryViewModel.kt @@ -8,11 +8,12 @@ import org.openedx.core.system.connection.NetworkConnection import org.openedx.core.utils.UrlUtils class WebViewDiscoveryViewModel( + private val querySearch: String, private val config: Config, private val networkConnection: NetworkConnection, private val corePreferences: CorePreferences, private val router: DiscoveryRouter, - private val querySearch: String, + private val analytics: DiscoveryAnalytics, ) : BaseViewModel() { val uriScheme: String get() = config.getUriScheme() @@ -59,4 +60,26 @@ class WebViewDiscoveryViewModel( fun navigateToSignIn(fragmentManager: FragmentManager) { router.navigateToSignIn(fragmentManager, null, null) } + + fun courseInfoClickedEvent(courseId: String) { + logEvent(DiscoveryAnalyticsEvent.COURSE_INFO, courseId) + } + + fun programInfoClickedEvent(courseId: String) { + logEvent(DiscoveryAnalyticsEvent.PROGRAM_INFO, courseId) + } + + private fun logEvent( + event: DiscoveryAnalyticsEvent, + courseId: String, + ) { + analytics.logEvent( + event.eventName, + buildMap { + put(DiscoveryAnalyticsKey.NAME.key, event.biValue) + put(DiscoveryAnalyticsKey.COURSE_ID.key, courseId) + put(DiscoveryAnalyticsKey.CATEGORY.key, DiscoveryAnalyticsKey.DISCOVERY.key) + } + ) + } } diff --git a/discussion/src/main/java/org/openedx/discussion/presentation/DiscussionAnalytics.kt b/discussion/src/main/java/org/openedx/discussion/presentation/DiscussionAnalytics.kt index 79b2130bd..a4457e699 100644 --- a/discussion/src/main/java/org/openedx/discussion/presentation/DiscussionAnalytics.kt +++ b/discussion/src/main/java/org/openedx/discussion/presentation/DiscussionAnalytics.kt @@ -9,4 +9,6 @@ interface DiscussionAnalytics { topicId: String, topicName: String ) -} \ No newline at end of file + + fun logEvent(event: String, params: Map) +} 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 66d43b607..bcab1126d 100644 --- a/profile/src/main/java/org/openedx/profile/presentation/ProfileAnalytics.kt +++ b/profile/src/main/java/org/openedx/profile/presentation/ProfileAnalytics.kt @@ -1,15 +1,85 @@ package org.openedx.profile.presentation interface ProfileAnalytics { - fun profileEditClickedEvent() - fun profileEditDoneClickedEvent() - fun profileDeleteAccountClickedEvent() - fun profileVideoSettingsClickedEvent() - fun privacyPolicyClickedEvent() - fun termsOfUseClickedEvent() - fun cookiePolicyClickedEvent() - fun dataSellClickedEvent() - fun faqClickedEvent() - fun emailSupportClickedEvent() - fun logoutEvent(force: Boolean) + fun logEvent(event: String, params: Map) +} + +enum class ProfileAnalyticsEvent(val eventName: String, val biValue: String) { + EDIT_CLICKED( + "Profile:Edit Clicked", + "edx.bi.app.profile.edit.clicked" + ), + SWITCH_PROFILE( + "Profile:Switch Profile", + "edx.bi.app.profile.switch_profile.clicked" + ), + EDIT_DONE_CLICKED( + "Profile:Edit Done Clicked", + "edx.bi.app.profile.edit_done.clicked" + ), + VIDEO_SETTING_CLICKED( + "Profile:Video Setting Clicked", + "edx.bi.app.profile.video_setting.clicked" + ), + CONTACT_SUPPORT_CLICKED( + "Profile:Contact Support Clicked", + "edx.bi.app.profile.email_support.clicked" + ), + FAQ_CLICKED( + "Profile:FAQ Clicked", + "edx.bi.app.profile.faq.clicked" + ), + TERMS_OF_USE_CLICKED( + "Profile:Terms of Use Clicked", + "edx.bi.app.profile.terms_of_use.clicked" + ), + PRIVACY_POLICY_CLICKED( + "Profile:Privacy Policy Clicked", + "edx.bi.app.profile.privacy_policy.clicked" + ), + COOKIE_POLICY_CLICKED( + "Profile:Cookie Policy Clicked", + "edx.bi.app.profile.cookie_policy.clicked" + ), + DATA_SELL_CLICKED( + "Profile:Data Sell Clicked", + "edx.bi.app.profile.do_not_sell_data.clicked" + ), + DELETE_ACCOUNT_CLICKED( + "Profile:Delete Account Clicked", + "edx.bi.app.profile.delete_account.clicked" + ), + USER_DELETE_ACCOUNT_CLICKED( + "Profile:User Delete Account Success", + "edx.bi.app.profile.user.delete_account.clicked" + ), + DELETE_ACCOUNT_SUCCESS( + "Profile:Delete Account Success", + "edx.bi.app.profile.delete_account.success" + ), + WIFI_TOGGLE( + "Profile:Wifi Toggle", + "edx.bi.app.profile.wifi_toggle.action" + ), + LOGOUT_CLICKED( + "Profile:Logout Clicked", + "edx.bi.app.profile.logout.clicked" + ), + LOGGED_OUT( + "Profile:Logged Out", + "edx.bi.app.user.logout" + ), +} + +enum class ProfileAnalyticsKey(val key: String) { + NAME("name"), + CATEGORY("category"), + PROFILE("profile"), + ACTION("action"), + FULL_PROFILE("full_profile"), + LIMITED_PROFILE("limited_profile"), + SUCCESS("success"), + FORCE("force"), + TRUE("true"), + FALSE("false"), } diff --git a/profile/src/main/java/org/openedx/profile/presentation/delete/DeleteProfileViewModel.kt b/profile/src/main/java/org/openedx/profile/presentation/delete/DeleteProfileViewModel.kt index 1f39bd03f..475d1be6f 100644 --- a/profile/src/main/java/org/openedx/profile/presentation/delete/DeleteProfileViewModel.kt +++ b/profile/src/main/java/org/openedx/profile/presentation/delete/DeleteProfileViewModel.kt @@ -3,6 +3,7 @@ package org.openedx.profile.presentation.delete import androidx.lifecycle.LiveData import androidx.lifecycle.MutableLiveData import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.launch import org.openedx.core.BaseViewModel import org.openedx.core.R import org.openedx.core.UIMessage @@ -11,15 +12,18 @@ import org.openedx.core.extension.isInternetError import org.openedx.core.system.EdxError import org.openedx.core.system.ResourceManager import org.openedx.profile.domain.interactor.ProfileInteractor +import org.openedx.profile.presentation.ProfileAnalytics +import org.openedx.profile.presentation.ProfileAnalyticsEvent +import org.openedx.profile.presentation.ProfileAnalyticsKey import org.openedx.profile.system.notifier.AccountDeactivated import org.openedx.profile.system.notifier.ProfileNotifier -import kotlinx.coroutines.launch class DeleteProfileViewModel( private val resourceManager: ResourceManager, private val interactor: ProfileInteractor, private val notifier: ProfileNotifier, - private val validator: Validator + private val validator: Validator, + private val analytics: ProfileAnalytics, ) : BaseViewModel() { private val _uiState = MutableLiveData() @@ -32,6 +36,7 @@ class DeleteProfileViewModel( fun deleteProfile(password: String) { + logDeleteProfileClickedEvent() if (!validator.isPasswordValid(password)) { _uiState.value = DeleteProfileFragmentUIState.Error(resourceManager.getString(org.openedx.profile.R.string.profile_invalid_password)) @@ -42,6 +47,7 @@ class DeleteProfileViewModel( try { interactor.deactivateAccount(password) _uiState.value = DeleteProfileFragmentUIState.Success + logDeleteProfileEvent(true) notifier.send(AccountDeactivated()) } catch (e: Exception) { if (e.isInternetError()) { @@ -56,7 +62,32 @@ class DeleteProfileViewModel( _uiState.value = DeleteProfileFragmentUIState.Error(resourceManager.getString(org.openedx.profile.R.string.profile_password_is_incorrect)) } + logDeleteProfileEvent(false) } } } + + private fun logDeleteProfileClickedEvent() { + logEvent(ProfileAnalyticsEvent.USER_DELETE_ACCOUNT_CLICKED) + } + + private fun logDeleteProfileEvent(isSuccess: Boolean) { + logEvent( + ProfileAnalyticsEvent.DELETE_ACCOUNT_SUCCESS, + buildMap { + put(ProfileAnalyticsKey.SUCCESS.key, isSuccess) + } + ) + } + + private fun logEvent(event: ProfileAnalyticsEvent, param: Map = emptyMap()) { + analytics.logEvent( + event.eventName, + buildMap { + put(ProfileAnalyticsKey.NAME.key, event.biValue) + put(ProfileAnalyticsKey.CATEGORY.key, ProfileAnalyticsKey.PROFILE.key) + putAll(param) + } + ) + } } diff --git a/profile/src/main/java/org/openedx/profile/presentation/edit/EditProfileViewModel.kt b/profile/src/main/java/org/openedx/profile/presentation/edit/EditProfileViewModel.kt index b9f4c0991..7505d5c88 100644 --- a/profile/src/main/java/org/openedx/profile/presentation/edit/EditProfileViewModel.kt +++ b/profile/src/main/java/org/openedx/profile/presentation/edit/EditProfileViewModel.kt @@ -4,17 +4,19 @@ import android.net.Uri import androidx.lifecycle.LiveData import androidx.lifecycle.MutableLiveData 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.profile.domain.model.Account import org.openedx.core.extension.isInternetError import org.openedx.core.system.ResourceManager import org.openedx.profile.domain.interactor.ProfileInteractor +import org.openedx.profile.domain.model.Account import org.openedx.profile.presentation.ProfileAnalytics +import org.openedx.profile.presentation.ProfileAnalyticsEvent +import org.openedx.profile.presentation.ProfileAnalyticsKey import org.openedx.profile.system.notifier.AccountUpdated import org.openedx.profile.system.notifier.ProfileNotifier -import kotlinx.coroutines.launch import java.io.File class EditProfileViewModel( @@ -22,7 +24,7 @@ class EditProfileViewModel( private val resourceManager: ResourceManager, private val notifier: ProfileNotifier, private val analytics: ProfileAnalytics, - account: Account + account: Account, ) : BaseViewModel() { private val _uiState = MutableLiveData() @@ -49,6 +51,16 @@ class EditProfileViewModel( set(value) { field = value _uiState.value = EditProfileUIState(account, isLimited = value) + logProfileEvent( + ProfileAnalyticsEvent.SWITCH_PROFILE, + buildMap { + put( + ProfileAnalyticsKey.ACTION.key, + if (isLimitedProfile) ProfileAnalyticsKey.LIMITED_PROFILE.key + else ProfileAnalyticsKey.FULL_PROFILE.key + ) + } + ) } private val _showLeaveDialog = MutableLiveData() @@ -128,11 +140,24 @@ class EditProfileViewModel( } fun profileEditDoneClickedEvent() { - analytics.profileEditDoneClickedEvent() + logProfileEvent(ProfileAnalyticsEvent.EDIT_DONE_CLICKED) } fun profileDeleteAccountClickedEvent() { - analytics.profileDeleteAccountClickedEvent() + logProfileEvent(ProfileAnalyticsEvent.DELETE_ACCOUNT_CLICKED) } + private fun logProfileEvent( + event: ProfileAnalyticsEvent, + params: Map = emptyMap(), + ) { + analytics.logEvent( + event = event.eventName, + params = buildMap { + put(ProfileAnalyticsKey.NAME.key, event.biValue) + put(ProfileAnalyticsKey.CATEGORY.key, ProfileAnalyticsKey.PROFILE.key) + putAll(params) + } + ) + } } 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 2ed5818ad..53a982509 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 @@ -29,6 +29,8 @@ 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.ProfileAnalyticsEvent +import org.openedx.profile.presentation.ProfileAnalyticsKey import org.openedx.profile.presentation.ProfileRouter import org.openedx.profile.system.notifier.AccountDeactivated import org.openedx.profile.system.notifier.AccountUpdated @@ -45,7 +47,7 @@ class ProfileViewModel( private val workerController: DownloadWorkerController, private val analytics: ProfileAnalytics, private val router: ProfileRouter, - private val appUpgradeNotifier: AppUpgradeNotifier + private val appUpgradeNotifier: AppUpgradeNotifier, ) : BaseViewModel() { private val _uiState: MutableStateFlow = @@ -134,12 +136,19 @@ class ProfileViewModel( } fun logout() { + logProfileEvent(ProfileAnalyticsEvent.LOGOUT_CLICKED) viewModelScope.launch { try { workerController.removeModels() withContext(dispatcher) { interactor.logout() } + logProfileEvent( + event = ProfileAnalyticsEvent.LOGGED_OUT, + params = buildMap { + put(ProfileAnalyticsKey.FORCE.key, ProfileAnalyticsKey.FALSE.key) + } + ) } catch (e: Exception) { if (e.isInternetError()) { _uiMessage.value = @@ -150,7 +159,6 @@ class ProfileViewModel( } } finally { cookieManager.clearWebViewCookie() - analytics.logoutEvent(false) _successLogout.value = true } } @@ -171,12 +179,12 @@ class ProfileViewModel( data.account ) } - analytics.profileEditClickedEvent() + logProfileEvent(ProfileAnalyticsEvent.EDIT_CLICKED) } fun profileVideoSettingsClicked(fragmentManager: FragmentManager) { router.navigateToVideoSettings(fragmentManager) - analytics.profileVideoSettingsClickedEvent() + logProfileEvent(ProfileAnalyticsEvent.VIDEO_SETTING_CLICKED) } fun privacyPolicyClicked(fragmentManager: FragmentManager) { @@ -185,7 +193,7 @@ class ProfileViewModel( title = resourceManager.getString(R.string.core_privacy_policy), url = configuration.agreementUrls.privacyPolicyUrl, ) - analytics.privacyPolicyClickedEvent() + logProfileEvent(ProfileAnalyticsEvent.PRIVACY_POLICY_CLICKED) } fun cookiePolicyClicked(fragmentManager: FragmentManager) { @@ -194,7 +202,7 @@ class ProfileViewModel( title = resourceManager.getString(R.string.core_cookie_policy), url = configuration.agreementUrls.cookiePolicyUrl, ) - analytics.cookiePolicyClickedEvent() + logProfileEvent(ProfileAnalyticsEvent.COOKIE_POLICY_CLICKED) } fun dataSellClicked(fragmentManager: FragmentManager) { @@ -203,11 +211,11 @@ class ProfileViewModel( title = resourceManager.getString(R.string.core_data_sell), url = configuration.agreementUrls.dataSellConsentUrl, ) - analytics.dataSellClickedEvent() + logProfileEvent(ProfileAnalyticsEvent.DATA_SELL_CLICKED) } fun faqClicked() { - analytics.faqClickedEvent() + logProfileEvent(ProfileAnalyticsEvent.FAQ_CLICKED) } fun termsOfUseClicked(fragmentManager: FragmentManager) { @@ -216,7 +224,7 @@ class ProfileViewModel( title = resourceManager.getString(R.string.core_terms_of_use), url = configuration.agreementUrls.tosUrl, ) - analytics.termsOfUseClickedEvent() + logProfileEvent(ProfileAnalyticsEvent.TERMS_OF_USE_CLICKED) } fun emailSupportClicked(context: Context) { @@ -225,7 +233,7 @@ class ProfileViewModel( feedbackEmailAddress = config.getFeedbackEmailAddress(), appVersion = appData.versionName ) - analytics.emailSupportClickedEvent() + logProfileEvent(ProfileAnalyticsEvent.CONTACT_SUPPORT_CLICKED) } fun appVersionClickedEvent(context: Context) { @@ -239,4 +247,17 @@ class ProfileViewModel( ) } + private fun logProfileEvent( + event: ProfileAnalyticsEvent, + params: Map = emptyMap(), + ) { + analytics.logEvent( + event = event.eventName, + params = buildMap { + put(ProfileAnalyticsKey.NAME.key, event.biValue) + put(ProfileAnalyticsKey.CATEGORY.key, ProfileAnalyticsKey.PROFILE.key) + putAll(params) + } + ) + } } 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 1fba67564..19a19245f 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 @@ -48,8 +48,6 @@ import androidx.compose.ui.unit.dp import androidx.fragment.app.Fragment import org.koin.android.ext.android.inject import org.koin.androidx.viewmodel.ext.android.viewModel -import org.openedx.core.presentation.settings.VideoQualityType -import org.openedx.core.ui.BackBtn import org.openedx.core.domain.model.VideoSettings import org.openedx.core.ui.Toolbar import org.openedx.core.ui.WindowSize @@ -78,7 +76,7 @@ class VideoSettingsFragment : Fragment() { override fun onCreateView( inflater: LayoutInflater, container: ViewGroup?, - savedInstanceState: Bundle? + savedInstanceState: Bundle?, ) = ComposeView(requireContext()).apply { setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed) setContent { @@ -97,14 +95,10 @@ class VideoSettingsFragment : Fragment() { viewModel.setWifiDownloadOnly(it) }, videoStreamingQualityClick = { - router.navigateToVideoQuality( - requireActivity().supportFragmentManager, VideoQualityType.Streaming - ) + viewModel.navigateToVideoStreamingQuality(requireActivity().supportFragmentManager) }, videoDownloadQualityClick = { - router.navigateToVideoQuality( - requireActivity().supportFragmentManager, VideoQualityType.Download - ) + viewModel.navigateToVideoDownloadQuality(requireActivity().supportFragmentManager) } ) } diff --git a/profile/src/main/java/org/openedx/profile/presentation/settings/video/VideoSettingsViewModel.kt b/profile/src/main/java/org/openedx/profile/presentation/settings/video/VideoSettingsViewModel.kt index f5ca673c6..475ff228a 100644 --- a/profile/src/main/java/org/openedx/profile/presentation/settings/video/VideoSettingsViewModel.kt +++ b/profile/src/main/java/org/openedx/profile/presentation/settings/video/VideoSettingsViewModel.kt @@ -1,5 +1,6 @@ package org.openedx.profile.presentation.settings.video +import androidx.fragment.app.FragmentManager import androidx.lifecycle.LifecycleOwner import androidx.lifecycle.LiveData import androidx.lifecycle.MutableLiveData @@ -9,12 +10,19 @@ import kotlinx.coroutines.launch import org.openedx.core.BaseViewModel import org.openedx.core.data.storage.CorePreferences import org.openedx.core.domain.model.VideoSettings +import org.openedx.core.presentation.settings.VideoQualityType import org.openedx.core.system.notifier.VideoNotifier import org.openedx.core.system.notifier.VideoQualityChanged +import org.openedx.profile.presentation.ProfileAnalytics +import org.openedx.profile.presentation.ProfileAnalyticsEvent +import org.openedx.profile.presentation.ProfileAnalyticsKey +import org.openedx.profile.presentation.ProfileRouter class VideoSettingsViewModel( private val preferencesManager: CorePreferences, - private val notifier: VideoNotifier + private val notifier: VideoNotifier, + private val analytics: ProfileAnalytics, + private val router: ProfileRouter, ) : BaseViewModel() { private val _videoSettings = MutableLiveData() @@ -43,6 +51,40 @@ class VideoSettingsViewModel( val currentSettings = preferencesManager.videoSettings preferencesManager.videoSettings = currentSettings.copy(wifiDownloadOnly = value) _videoSettings.value = preferencesManager.videoSettings + logProfileEvent( + ProfileAnalyticsEvent.WIFI_TOGGLE, + buildMap { + put( + ProfileAnalyticsKey.ACTION.key, + if (value) ProfileAnalyticsKey.TRUE.key else ProfileAnalyticsKey.FALSE.key + ) + } + ) + } + + fun navigateToVideoStreamingQuality(fragmentManager: FragmentManager) { + router.navigateToVideoQuality( + fragmentManager, VideoQualityType.Streaming + ) } + fun navigateToVideoDownloadQuality(fragmentManager: FragmentManager) { + router.navigateToVideoQuality( + fragmentManager, VideoQualityType.Download + ) + } + + private fun logProfileEvent( + event: ProfileAnalyticsEvent, + params: Map = emptyMap(), + ) { + analytics.logEvent( + event = event.eventName, + params = buildMap { + put(ProfileAnalyticsKey.NAME.key, event.biValue) + put(ProfileAnalyticsKey.CATEGORY.key, ProfileAnalyticsKey.PROFILE.key) + putAll(params) + } + ) + } } diff --git a/profile/src/test/java/org/openedx/profile/presentation/edit/EditProfileViewModelTest.kt b/profile/src/test/java/org/openedx/profile/presentation/edit/EditProfileViewModelTest.kt index 3a6dd29bd..bfe6bb0b3 100644 --- a/profile/src/test/java/org/openedx/profile/presentation/edit/EditProfileViewModelTest.kt +++ b/profile/src/test/java/org/openedx/profile/presentation/edit/EditProfileViewModelTest.kt @@ -108,9 +108,11 @@ class EditProfileViewModelTest { EditProfileViewModel(interactor, resourceManager, notifier, analytics, account) coEvery { interactor.updateAccount(any()) } returns account coEvery { notifier.send(any()) } returns Unit + every { analytics.logEvent(any(), any()) } returns Unit viewModel.updateAccount(emptyMap()) advanceUntilIdle() + verify { analytics.logEvent(any(), any()) } coVerify(exactly = 1) { interactor.updateAccount(any()) } assert(viewModel.uiMessage.value == null) @@ -164,10 +166,12 @@ class EditProfileViewModelTest { coEvery { interactor.setProfileImage(any(), any()) } returns Unit coEvery { interactor.updateAccount(any()) } returns account coEvery { notifier.send(any()) } returns Unit + every { analytics.logEvent(any(), any()) } returns Unit viewModel.updateAccountAndImage(emptyMap(), file, "") advanceUntilIdle() + verify(exactly = 1) { analytics.logEvent(any(), any()) } coVerify(exactly = 1) { interactor.updateAccount(any()) } coVerify(exactly = 1) { interactor.setProfileImage(any(), any()) } 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 7112eac51..89f6f2623 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 @@ -223,7 +223,7 @@ class ProfileViewModelTest { ) coEvery { interactor.logout() } throws UnknownHostException() coEvery { workerController.removeModels() } returns Unit - every { analytics.logoutEvent(false) } returns Unit + every { analytics.logEvent(any(), any()) } returns Unit every { cookieManager.clearWebViewCookie() } returns Unit viewModel.logout() advanceUntilIdle() @@ -253,7 +253,7 @@ class ProfileViewModelTest { ) coEvery { interactor.logout() } throws Exception() coEvery { workerController.removeModels() } returns Unit - every { analytics.logoutEvent(false) } returns Unit + every { analytics.logEvent(any(), any()) } returns Unit every { cookieManager.clearWebViewCookie() } returns Unit viewModel.logout() advanceUntilIdle() @@ -261,7 +261,7 @@ class ProfileViewModelTest { coVerify(exactly = 1) { interactor.logout() } verify(exactly = 1) { appUpgradeNotifier.notifier } verify(exactly = 1) { cookieManager.clearWebViewCookie() } - verify { analytics.logoutEvent(false) } + verify { analytics.logEvent(any(), any()) } val message = viewModel.uiMessage.value as? UIMessage.SnackBarMessage assertEquals(somethingWrong, message?.message) @@ -285,14 +285,14 @@ class ProfileViewModelTest { ) coEvery { interactor.getCachedAccount() } returns mockk() coEvery { interactor.getAccount() } returns mockk() - every { analytics.logoutEvent(false) } returns Unit + every { analytics.logEvent(any(), any()) } returns Unit coEvery { interactor.logout() } returns Unit coEvery { workerController.removeModels() } returns Unit every { cookieManager.clearWebViewCookie() } returns Unit viewModel.logout() advanceUntilIdle() coVerify(exactly = 1) { interactor.logout() } - verify { analytics.logoutEvent(false) } + verify(exactly = 2) { analytics.logEvent(any(), any()) } verify(exactly = 1) { appUpgradeNotifier.notifier } verify(exactly = 1) { cookieManager.clearWebViewCookie() } diff --git a/whatsnew/src/main/java/org/openedx/whatsnew/presentation/WhatsNewAnalytics.kt b/whatsnew/src/main/java/org/openedx/whatsnew/presentation/WhatsNewAnalytics.kt new file mode 100644 index 000000000..8568a6860 --- /dev/null +++ b/whatsnew/src/main/java/org/openedx/whatsnew/presentation/WhatsNewAnalytics.kt @@ -0,0 +1,28 @@ +package org.openedx.whatsnew.presentation + +interface WhatsNewAnalytics { + fun logEvent(event: String, params: Map) +} + +enum class WhatsNewAnalyticsEvent(val eventName: String, val biValue: String) { + WHATS_NEW_VIEW( + "WhatsNew:Pop up Viewed", + "edx.bi.app.whats_new.popup.viewed" + ), + WHATS_NEW_CLOSE( + "WhatsNew:Close", + "edx.bi.app.whats_new.close" + ), + WHATS_NEW_DONE( + "WhatsNew:Done", + "edx.bi.app.whats_new.done" + ), +} + +enum class WhatsNewAnalyticsKey(val key: String) { + NAME("name"), + CATEGORY("category"), + WHATS_NEW("whats_new"), + TOTAL_SCREENS("total_screens"), + CURRENTLY_VIEWED("currently_viewed"), +} 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 index 6ba02e558..da0458054 100644 --- a/whatsnew/src/main/java/org/openedx/whatsnew/presentation/whatsnew/WhatsNewFragment.kt +++ b/whatsnew/src/main/java/org/openedx/whatsnew/presentation/whatsnew/WhatsNewFragment.kt @@ -54,10 +54,8 @@ 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 @@ -66,8 +64,6 @@ 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 @@ -81,9 +77,6 @@ class WhatsNewFragment : Fragment() { requireArguments().getString(ARG_INFO_TYPE, null) ) } - private val preferencesManager by inject() - private val router by inject() - private val appData: AppData by inject() override fun onCreateView( inflater: LayoutInflater, @@ -99,15 +92,15 @@ class WhatsNewFragment : Fragment() { windowSize = windowSize, whatsNewItem = whatsNewItem.value, onCloseClick = { - val versionName = appData.versionName - preferencesManager.lastWhatsNewVersion = versionName - router.navigateToMain( - parentFragmentManager, - viewModel.courseId, - viewModel.infoType - ) + viewModel.logWhatsNewDismissed(it) + viewModel.navigateToMain(parentFragmentManager) + }, + onDoneClick = { + viewModel.logWhatsNewCompleted() + viewModel.navigateToMain(parentFragmentManager) } ) + viewModel.logWhatsNewViewed() } } } @@ -132,7 +125,8 @@ class WhatsNewFragment : Fragment() { fun WhatsNewScreen( windowSize: WindowSize, whatsNewItem: WhatsNewItem?, - onCloseClick: () -> Unit + onCloseClick: (Int) -> Unit, + onDoneClick: () -> Unit, ) { whatsNewItem?.let { item -> OpenEdXTheme { @@ -151,6 +145,7 @@ fun WhatsNewScreen( topBar = { WhatsNewTopBar( windowSize = windowSize, + pagerState = pagerState, onCloseClick = onCloseClick ) }, @@ -162,7 +157,7 @@ fun WhatsNewScreen( modifier = Modifier.padding(paddingValues), whatsNewItem = item, pagerState = pagerState, - onCloseClick = onCloseClick + onDoneClick = onDoneClick ) else -> @@ -170,7 +165,7 @@ fun WhatsNewScreen( modifier = Modifier.padding(paddingValues), whatsNewItem = item, pagerState = pagerState, - onCloseClick = onCloseClick + onDoneClick = onDoneClick ) } } @@ -179,10 +174,12 @@ fun WhatsNewScreen( } } +@OptIn(ExperimentalFoundationApi::class) @Composable private fun WhatsNewTopBar( windowSize: WindowSize, - onCloseClick: () -> Unit + pagerState: PagerState, + onCloseClick: (Int) -> Unit, ) { val topBarWidth by remember(key1 = windowSize) { mutableStateOf( @@ -219,7 +216,7 @@ private fun WhatsNewTopBar( modifier = Modifier .testTag("ib_close") .padding(end = 16.dp), - onClick = onCloseClick + onClick = { onCloseClick(pagerState.currentPage + 1) } ) { Icon( imageVector = Icons.Filled.Close, @@ -238,7 +235,7 @@ private fun WhatsNewScreenPortrait( modifier: Modifier = Modifier, whatsNewItem: WhatsNewItem, pagerState: PagerState, - onCloseClick: () -> Unit + onDoneClick: () -> Unit, ) { OpenEdXTheme { val coroutineScope = rememberCoroutineScope() @@ -330,7 +327,7 @@ private fun WhatsNewScreenPortrait( pagerState.animateScrollToPage(pagerState.currentPage + 1) } } else { - onCloseClick() + onDoneClick() } } } @@ -347,7 +344,7 @@ private fun WhatsNewScreenLandscape( modifier: Modifier = Modifier, whatsNewItem: WhatsNewItem, pagerState: PagerState, - onCloseClick: () -> Unit + onDoneClick: () -> Unit, ) { OpenEdXTheme { val coroutineScope = rememberCoroutineScope() @@ -435,7 +432,7 @@ private fun WhatsNewScreenLandscape( pagerState.animateScrollToPage(pagerState.currentPage + 1) } } else { - onCloseClick() + onDoneClick() } } } @@ -475,7 +472,7 @@ private fun WhatsNewPortraitPreview() { OpenEdXTheme { WhatsNewScreenPortrait( whatsNewItem = whatsNewItemPreview, - onCloseClick = {}, + onDoneClick = {}, pagerState = rememberPagerState { 4 } ) } @@ -499,7 +496,7 @@ private fun WhatsNewLandscapePreview() { OpenEdXTheme { WhatsNewScreenLandscape( whatsNewItem = whatsNewItemPreview, - onCloseClick = {}, + onDoneClick = {}, 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 index ba4ee3ee5..51f0f9646 100644 --- a/whatsnew/src/main/java/org/openedx/whatsnew/presentation/whatsnew/WhatsNewViewModel.kt +++ b/whatsnew/src/main/java/org/openedx/whatsnew/presentation/whatsnew/WhatsNewViewModel.kt @@ -2,14 +2,25 @@ package org.openedx.whatsnew.presentation.whatsnew import androidx.compose.runtime.State import androidx.compose.runtime.mutableStateOf +import androidx.fragment.app.FragmentManager import org.openedx.core.BaseViewModel +import org.openedx.core.presentation.global.AppData import org.openedx.whatsnew.WhatsNewManager +import org.openedx.whatsnew.WhatsNewRouter +import org.openedx.whatsnew.data.storage.WhatsNewPreferences import org.openedx.whatsnew.domain.model.WhatsNewItem +import org.openedx.whatsnew.presentation.WhatsNewAnalytics +import org.openedx.whatsnew.presentation.WhatsNewAnalyticsEvent +import org.openedx.whatsnew.presentation.WhatsNewAnalyticsKey class WhatsNewViewModel( val courseId: String?, val infoType: String?, - private val whatsNewManager: WhatsNewManager + private val whatsNewManager: WhatsNewManager, + private val analytics: WhatsNewAnalytics, + private val router: WhatsNewRouter, + private val preferencesManager: WhatsNewPreferences, + private val appData: AppData, ) : BaseViewModel() { private val _whatsNewItem = mutableStateOf(null) @@ -23,4 +34,40 @@ class WhatsNewViewModel( private fun getNewestData() { _whatsNewItem.value = whatsNewManager.getNewestData() } + + fun navigateToMain(fm: FragmentManager) { + val versionName = appData.versionName + preferencesManager.lastWhatsNewVersion = versionName + router.navigateToMain( + fm, + courseId, + infoType + ) + } + + fun logWhatsNewViewed() { + logEvent(WhatsNewAnalyticsEvent.WHATS_NEW_VIEW) + } + + fun logWhatsNewDismissed(currentlyViewed: Int) { + logEvent(WhatsNewAnalyticsEvent.WHATS_NEW_CLOSE, currentlyViewed) + } + + fun logWhatsNewCompleted() { + logEvent(WhatsNewAnalyticsEvent.WHATS_NEW_DONE) + } + + private fun logEvent(event: WhatsNewAnalyticsEvent, currentlyViewed: Int? = null) { + analytics.logEvent( + event.eventName, + buildMap { + put(WhatsNewAnalyticsKey.NAME.key, event.biValue) + put(WhatsNewAnalyticsKey.CATEGORY.key, WhatsNewAnalyticsKey.WHATS_NEW.key) + put(WhatsNewAnalyticsKey.TOTAL_SCREENS.key, whatsNewItem.value?.messages?.size) + currentlyViewed?.let { + put(WhatsNewAnalyticsKey.CURRENTLY_VIEWED.key, it) + } + } + ) + } } diff --git a/whatsnew/src/test/java/org/openedx/whatsnew/WhatsNewViewModelTest.kt b/whatsnew/src/test/java/org/openedx/whatsnew/WhatsNewViewModelTest.kt index 307946b19..c6cbe3573 100644 --- a/whatsnew/src/test/java/org/openedx/whatsnew/WhatsNewViewModelTest.kt +++ b/whatsnew/src/test/java/org/openedx/whatsnew/WhatsNewViewModelTest.kt @@ -5,12 +5,19 @@ import io.mockk.mockk import io.mockk.verify import kotlinx.coroutines.test.runTest import org.junit.Test +import org.openedx.core.presentation.global.AppData +import org.openedx.whatsnew.data.storage.WhatsNewPreferences import org.openedx.whatsnew.domain.model.WhatsNewItem +import org.openedx.whatsnew.presentation.WhatsNewAnalytics import org.openedx.whatsnew.presentation.whatsnew.WhatsNewViewModel class WhatsNewViewModelTest { private val whatsNewManager = mockk() + private val analytics = mockk() + private val router = mockk() + private val preferencesManager = mockk() + private val appData = mockk() private val whatsNewItem = WhatsNewItem( version = "1.0.0", @@ -21,7 +28,15 @@ class WhatsNewViewModelTest { fun `getNewestData success`() = runTest { every { whatsNewManager.getNewestData() } returns whatsNewItem - val viewModel = WhatsNewViewModel("", "", whatsNewManager) + val viewModel = WhatsNewViewModel( + "", + "", + whatsNewManager, + analytics, + router, + preferencesManager, + appData + ) verify(exactly = 1) { whatsNewManager.getNewestData() } assert(viewModel.whatsNewItem.value == whatsNewItem) From ef844a97d90027703083a0dd32e341a34f9d61c5 Mon Sep 17 00:00:00 2001 From: Volodymyr Chekyrta <127732735+volodymyr-chekyrta@users.noreply.github.com> Date: Thu, 28 Mar 2024 11:44:57 +0200 Subject: [PATCH 29/39] Code Health: Warnings (#267) * refactor: remove unused files and variables, replace deprecated code * refactor: replace android.defaults.buildfeatures.buildconfig property with module level buildFeature * fix: address feedback --- app/build.gradle | 1 + .../main/java/org/openedx/app/OpenEdXApp.kt | 5 +- .../app/data/networking/HeadersInterceptor.kt | 5 +- core/build.gradle | 1 + .../openedx/core/module/TranscriptManager.kt | 2 +- .../java/org/openedx/core/ui/LinkifyText.kt | 218 ------------------ .../presentation/dates/CourseDatesFragment.kt | 4 +- .../course/presentation/ui/CourseUI.kt | 7 - .../container/CourseUnitContainerFragment.kt | 3 - .../unit/video/VideoFullScreenFragment.kt | 2 +- .../dashboard/DashboardFragment.kt | 4 +- .../responses/DiscussionResponsesFragment.kt | 2 +- .../threads/DiscussionAddThreadFragment.kt | 2 +- .../presentation/ui/DiscussionUI.kt | 29 +-- gradle.properties | 3 +- .../profile/compose/ProfileView.kt | 12 +- 16 files changed, 38 insertions(+), 262 deletions(-) delete mode 100644 core/src/main/java/org/openedx/core/ui/LinkifyText.kt diff --git a/app/build.gradle b/app/build.gradle index 1e2f61772..2b0ab4f74 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -95,6 +95,7 @@ android { buildFeatures { viewBinding true compose true + buildConfig true } composeOptions { kotlinCompilerExtensionVersion = "$compose_compiler_version" diff --git a/app/src/main/java/org/openedx/app/OpenEdXApp.kt b/app/src/main/java/org/openedx/app/OpenEdXApp.kt index 0caa89137..7d1b81d32 100644 --- a/app/src/main/java/org/openedx/app/OpenEdXApp.kt +++ b/app/src/main/java/org/openedx/app/OpenEdXApp.kt @@ -3,8 +3,7 @@ package org.openedx.app import android.app.Application import com.braze.Braze import com.braze.configuration.BrazeConfig -import com.google.firebase.ktx.Firebase -import com.google.firebase.ktx.initialize +import com.google.firebase.FirebaseApp import io.branch.referral.Branch import org.koin.android.ext.android.inject import org.koin.android.ext.koin.androidContext @@ -29,7 +28,7 @@ class OpenEdXApp : Application() { ) } if (config.getFirebaseConfig().enabled) { - Firebase.initialize(this) + FirebaseApp.initializeApp(this) } if (config.getBranchConfig().enabled) { 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 7b1e203e2..c91b27184 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 @@ -24,8 +24,11 @@ class HeadersInterceptor( } addHeader("Accept", "application/json") + + val httpAgent = System.getProperty("http.agent") ?: "" addHeader( - "User-Agent", System.getProperty("http.agent") + " " + + "User-Agent", + httpAgent + " " + context.getString(org.openedx.core.R.string.app_name) + "/" + BuildConfig.APPLICATION_ID + "/" + BuildConfig.VERSION_NAME diff --git a/core/build.gradle b/core/build.gradle index 93928bfc8..8c4bdcc6f 100644 --- a/core/build.gradle +++ b/core/build.gradle @@ -85,6 +85,7 @@ android { buildFeatures { viewBinding true compose true + buildConfig true } composeOptions { kotlinCompilerExtensionVersion = "$compose_compiler_version" diff --git a/core/src/main/java/org/openedx/core/module/TranscriptManager.kt b/core/src/main/java/org/openedx/core/module/TranscriptManager.kt index f57f1a948..863586900 100644 --- a/core/src/main/java/org/openedx/core/module/TranscriptManager.kt +++ b/core/src/main/java/org/openedx/core/module/TranscriptManager.kt @@ -114,7 +114,7 @@ class TranscriptManager( private fun getTranscriptDir(): File? { val externalAppDir: File = FileUtil.getExternalAppDir(context) - if (externalAppDir != null) { + if (externalAppDir.exists()) { val videosDir = File(externalAppDir, Directories.VIDEOS.name) val transcriptDir = File(videosDir, Directories.SUBTITLES.name) transcriptDir.mkdirs() diff --git a/core/src/main/java/org/openedx/core/ui/LinkifyText.kt b/core/src/main/java/org/openedx/core/ui/LinkifyText.kt deleted file mode 100644 index ed482bb59..000000000 --- a/core/src/main/java/org/openedx/core/ui/LinkifyText.kt +++ /dev/null @@ -1,218 +0,0 @@ -package org.openedx.core.ui - -import android.os.Build -import android.text.SpannableString -import android.text.style.URLSpan -import android.text.util.Linkify -import androidx.compose.foundation.gestures.detectTapGestures -import androidx.compose.material.LocalTextStyle -import androidx.compose.material.Text -import androidx.compose.runtime.Composable -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.input.pointer.pointerInput -import androidx.compose.ui.platform.LocalUriHandler -import androidx.compose.ui.text.* -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.text.style.TextAlign -import androidx.compose.ui.text.style.TextDecoration -import androidx.compose.ui.text.style.TextOverflow -import androidx.compose.ui.unit.TextUnit - -@Composable -fun LinkifyText( - text: String, - modifier: Modifier = Modifier, - linkColor: Color = Color.Blue, - linkEntire: Boolean = false, - color: Color = Color.Unspecified, - fontSize: TextUnit = TextUnit.Unspecified, - fontStyle: FontStyle? = null, - fontWeight: FontWeight? = null, - fontFamily: FontFamily? = null, - letterSpacing: TextUnit = TextUnit.Unspecified, - textDecoration: TextDecoration? = null, - textAlign: TextAlign? = null, - lineHeight: TextUnit = TextUnit.Unspecified, - overflow: TextOverflow = TextOverflow.Clip, - softWrap: Boolean = true, - maxLines: Int = Int.MAX_VALUE, - onTextLayout: (TextLayoutResult) -> Unit = {}, - style: TextStyle = LocalTextStyle.current, - clickable: Boolean = true, - onClickLink: ((linkText: String) -> Unit)? = null -) { - val uriHandler = LocalUriHandler.current - val linkInfos = if (linkEntire) listOf(LinkInfo(text, 0, text.length)) else SpannableStr.getLinkInfos(text) - val annotatedString = buildAnnotatedString { - append(text) - linkInfos.forEach { - addStyle( - style = SpanStyle( - color = linkColor, - textDecoration = TextDecoration.Underline - ), - start = it.start, - end = it.end - ) - addStringAnnotation( - tag = "tag", - annotation = it.url, - start = it.start, - end = it.end - ) - } - } - if (clickable) { - ClickableText( - text = annotatedString, - modifier = modifier, - color = color, - fontSize = fontSize, - fontStyle = fontStyle, - fontWeight = fontWeight, - fontFamily = fontFamily, - letterSpacing = letterSpacing, - textDecoration = textDecoration, - textAlign = textAlign, - lineHeight = lineHeight, - overflow = overflow, - softWrap = softWrap, - maxLines = maxLines, - onTextLayout = onTextLayout, - style = style, - onClick = { offset -> - annotatedString.getStringAnnotations( - start = offset, - end = offset, - ).firstOrNull()?.let { result -> - if (linkEntire) { - onClickLink?.invoke(annotatedString.substring(result.start, result.end)) - } else { - uriHandler.openUri(result.item) - onClickLink?.invoke(annotatedString.substring(result.start, result.end)) - } - } - } - ) - } else { - Text( - text = annotatedString, - modifier = modifier, - color = color, - fontSize = fontSize, - fontStyle = fontStyle, - fontWeight = fontWeight, - fontFamily = fontFamily, - letterSpacing = letterSpacing, - textDecoration = textDecoration, - textAlign = textAlign, - lineHeight = lineHeight, - overflow = overflow, - softWrap = softWrap, - maxLines = maxLines, - onTextLayout = onTextLayout, - style = style - ) - } -} - -@Composable -private fun ClickableText( - text: AnnotatedString, - modifier: Modifier = Modifier, - color: Color = Color.Unspecified, - fontSize: TextUnit = TextUnit.Unspecified, - fontStyle: FontStyle? = null, - fontWeight: FontWeight? = null, - fontFamily: FontFamily? = null, - letterSpacing: TextUnit = TextUnit.Unspecified, - textDecoration: TextDecoration? = null, - textAlign: TextAlign? = null, - lineHeight: TextUnit = TextUnit.Unspecified, - overflow: TextOverflow = TextOverflow.Clip, - softWrap: Boolean = true, - maxLines: Int = Int.MAX_VALUE, - onTextLayout: (TextLayoutResult) -> Unit = {}, - style: TextStyle = LocalTextStyle.current, - onClick: (Int) -> Unit -) { - val layoutResult = remember { mutableStateOf(null) } - val pressIndicator = Modifier.pointerInput(onClick) { - detectTapGestures { pos -> - layoutResult.value?.let { layoutResult -> - onClick(layoutResult.getOffsetForPosition(pos)) - } - } - } - Text( - text = text, - modifier = modifier.then(pressIndicator), - color = color, - fontSize = fontSize, - fontStyle = fontStyle, - fontWeight = fontWeight, - fontFamily = fontFamily, - letterSpacing = letterSpacing, - textDecoration = textDecoration, - textAlign = textAlign, - lineHeight = lineHeight, - overflow = overflow, - softWrap = softWrap, - maxLines = maxLines, - onTextLayout = { - layoutResult.value = it - onTextLayout(it) - }, - style = style - ) -} - -private data class LinkInfo( - val url: String, - val start: Int, - val end: Int -) - -private class SpannableStr(source: CharSequence): SpannableString(source) { - companion object { - fun getLinkInfos(text: String): List { - val spannableStr = SpannableStr(text) - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { - Linkify.addLinks(spannableStr, Linkify.ALL) { str: String -> URLSpan(str) } - } else { - Linkify.addLinks(spannableStr, Linkify.ALL) - } - return spannableStr.linkInfos - } - } - private inner class Data( - val what: Any?, - val start: Int, - val end: Int - ) - private val spanList = mutableListOf() - - private val linkInfos: List - get() = spanList.filter { it.what is URLSpan }.map { - LinkInfo( - (it.what as URLSpan).url, - it.start, - it.end - ) - } - - override fun removeSpan(what: Any?) { - super.removeSpan(what) - spanList.removeAll { it.what == what } - } - - override fun setSpan(what: Any?, start: Int, end: Int, flags: Int) { - super.setSpan(what, start, end, flags) - spanList.add(Data(what, start, end)) - } -} \ No newline at end of file diff --git a/course/src/main/java/org/openedx/course/presentation/dates/CourseDatesFragment.kt b/course/src/main/java/org/openedx/course/presentation/dates/CourseDatesFragment.kt index 05a90b136..b65532f0b 100644 --- a/course/src/main/java/org/openedx/course/presentation/dates/CourseDatesFragment.kt +++ b/course/src/main/java/org/openedx/course/presentation/dates/CourseDatesFragment.kt @@ -46,7 +46,7 @@ import androidx.compose.material.Switch import androidx.compose.material.SwitchDefaults import androidx.compose.material.Text import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.KeyboardArrowRight +import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight import androidx.compose.material.icons.filled.KeyboardArrowUp import androidx.compose.material.pullrefresh.PullRefreshIndicator import androidx.compose.material.pullrefresh.pullRefresh @@ -750,7 +750,7 @@ private fun CourseDateItem( if (dateBlock.blockId.isNotEmpty() && dateBlock.learnerHasAccess) { Icon( - imageVector = Icons.Filled.KeyboardArrowRight, + imageVector = Icons.AutoMirrored.Filled.KeyboardArrowRight, tint = MaterialTheme.appColors.textDark, contentDescription = "Open Block Arrow", modifier = Modifier diff --git a/course/src/main/java/org/openedx/course/presentation/ui/CourseUI.kt b/course/src/main/java/org/openedx/course/presentation/ui/CourseUI.kt index 088f28d72..546348f3e 100644 --- a/course/src/main/java/org/openedx/course/presentation/ui/CourseUI.kt +++ b/course/src/main/java/org/openedx/course/presentation/ui/CourseUI.kt @@ -98,8 +98,6 @@ import org.openedx.core.ui.BackBtn import org.openedx.core.ui.IconText import org.openedx.core.ui.OpenEdXButton import org.openedx.core.ui.OpenEdXOutlinedButton -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.rememberWindowSize @@ -441,7 +439,6 @@ fun VideoTitle( @Composable fun NavigationUnitsButtons( - windowSize: WindowSize, nextButtonText: String, hasPrevBlock: Boolean, hasNextBlock: Boolean, @@ -1283,7 +1280,6 @@ private fun WarningLabelPreview() { private fun NavigationUnitsButtonsOnlyNextButtonPreview() { OpenEdXTheme { NavigationUnitsButtons( - windowSize = WindowSize(WindowType.Compact, WindowType.Compact), hasPrevBlock = true, hasNextBlock = true, isVerticalNavigation = true, @@ -1298,7 +1294,6 @@ private fun NavigationUnitsButtonsOnlyNextButtonPreview() { private fun NavigationUnitsButtonsOnlyFinishButtonPreview() { OpenEdXTheme { NavigationUnitsButtons( - windowSize = WindowSize(WindowType.Compact, WindowType.Compact), hasPrevBlock = true, hasNextBlock = false, isVerticalNavigation = true, @@ -1313,7 +1308,6 @@ private fun NavigationUnitsButtonsOnlyFinishButtonPreview() { private fun NavigationUnitsButtonsWithFinishPreview() { OpenEdXTheme { NavigationUnitsButtons( - windowSize = WindowSize(WindowType.Compact, WindowType.Compact), hasPrevBlock = true, hasNextBlock = false, isVerticalNavigation = true, @@ -1328,7 +1322,6 @@ private fun NavigationUnitsButtonsWithFinishPreview() { private fun NavigationUnitsButtonsWithNextPreview() { OpenEdXTheme { NavigationUnitsButtons( - windowSize = WindowSize(WindowType.Compact, WindowType.Compact), hasPrevBlock = true, hasNextBlock = true, isVerticalNavigation = true, diff --git a/course/src/main/java/org/openedx/course/presentation/unit/container/CourseUnitContainerFragment.kt b/course/src/main/java/org/openedx/course/presentation/unit/container/CourseUnitContainerFragment.kt index 9f868651e..fc7c9213f 100644 --- a/course/src/main/java/org/openedx/course/presentation/unit/container/CourseUnitContainerFragment.kt +++ b/course/src/main/java/org/openedx/course/presentation/unit/container/CourseUnitContainerFragment.kt @@ -34,7 +34,6 @@ import org.openedx.core.BlockType import org.openedx.core.extension.serializable import org.openedx.core.presentation.course.CourseViewMode import org.openedx.core.presentation.global.InsetHolder -import org.openedx.core.ui.rememberWindowSize import org.openedx.core.ui.theme.OpenEdXTheme import org.openedx.core.ui.theme.appColors import org.openedx.course.R @@ -428,10 +427,8 @@ class CourseUnitContainerFragment : Fragment(R.layout.fragment_course_unit_conta hasPrevBlock = hasPrev hasNextBlock = hasNext } - val windowSize = rememberWindowSize() NavigationUnitsButtons( - windowSize = windowSize, hasPrevBlock = hasPrevBlock, nextButtonText = nextButtonText, hasNextBlock = hasNextBlock, diff --git a/course/src/main/java/org/openedx/course/presentation/unit/video/VideoFullScreenFragment.kt b/course/src/main/java/org/openedx/course/presentation/unit/video/VideoFullScreenFragment.kt index c8a860cbc..3caa4d7c6 100644 --- a/course/src/main/java/org/openedx/course/presentation/unit/video/VideoFullScreenFragment.kt +++ b/course/src/main/java/org/openedx/course/presentation/unit/video/VideoFullScreenFragment.kt @@ -127,7 +127,7 @@ class VideoFullScreenFragment : Fragment(R.layout.fragment_video_full_screen) { exoPlayer?.prepare() exoPlayer?.playWhenReady = viewModel.isPlaying ?: false - playerView.setFullscreenButtonClickListener { isFullScreen -> + playerView.setFullscreenButtonClickListener { _ -> requireActivity().supportFragmentManager.popBackStackImmediate() } diff --git a/dashboard/src/main/java/org/openedx/dashboard/presentation/dashboard/DashboardFragment.kt b/dashboard/src/main/java/org/openedx/dashboard/presentation/dashboard/DashboardFragment.kt index 2b0e17d72..0e36bbe2e 100644 --- a/dashboard/src/main/java/org/openedx/dashboard/presentation/dashboard/DashboardFragment.kt +++ b/dashboard/src/main/java/org/openedx/dashboard/presentation/dashboard/DashboardFragment.kt @@ -34,7 +34,7 @@ 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.ArrowForward +import androidx.compose.material.icons.automirrored.filled.ArrowForward import androidx.compose.material.pullrefresh.PullRefreshIndicator import androidx.compose.material.pullrefresh.pullRefresh import androidx.compose.material.pullrefresh.rememberPullRefreshState @@ -468,7 +468,7 @@ private fun CourseItem( modifier = Modifier .testTag("ic_course_item") .size(15.dp), - imageVector = Icons.Filled.ArrowForward, + imageVector = Icons.AutoMirrored.Filled.ArrowForward, contentDescription = null, tint = MaterialTheme.appColors.primary ) 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 cafcf1cb2..b3d5a0d82 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 @@ -301,7 +301,7 @@ private fun DiscussionResponsesScreen( .padding(horizontal = 8.dp, vertical = 16.dp), internalPadding = internalPadding, comment = uiState.mainComment, - onClick = { action, commentId, bool -> + onClick = { action, _, bool -> onItemClick( action, uiState.mainComment.id, 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 7604b1927..afeffa652 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 @@ -423,7 +423,7 @@ private fun Tabs( .padding(vertical = 4.dp) .clip(RoundedCornerShape(20)) .border(1.dp, MaterialTheme.appColors.cardViewBorder, RoundedCornerShape(20)), - indicator = { tabPositions: List -> + indicator = { _ -> Box {} } ) { 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 8d6323bfd..7d2242850 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 @@ -27,8 +27,8 @@ import androidx.compose.material.MaterialTheme import androidx.compose.material.Surface import androidx.compose.material.Text import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.outlined.HelpOutline import androidx.compose.material.icons.filled.ChevronRight -import androidx.compose.material.icons.outlined.HelpOutline import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.ExperimentalComposeUiApi @@ -62,6 +62,7 @@ import org.openedx.discussion.domain.model.DiscussionComment import org.openedx.discussion.domain.model.DiscussionType import org.openedx.discussion.domain.model.Topic import org.openedx.discussion.presentation.comments.DiscussionCommentsFragment +import org.openedx.core.R as CoreR @Composable fun ThreadMainItem( @@ -73,7 +74,7 @@ fun ThreadMainItem( val profileImageUrl = if (thread.users?.get(thread.author)?.image?.hasImage == true) { thread.users[thread.author]?.image?.imageUrlFull } else { - org.openedx.core.R.drawable.core_ic_default_profile_picture + CoreR.drawable.core_ic_default_profile_picture } val votePainter = if (thread.voted) { @@ -106,11 +107,11 @@ fun ThreadMainItem( AsyncImage( model = ImageRequest.Builder(LocalContext.current) .data(profileImageUrl) - .error(org.openedx.core.R.drawable.core_ic_default_profile_picture) - .placeholder(org.openedx.core.R.drawable.core_ic_default_profile_picture) + .error(CoreR.drawable.core_ic_default_profile_picture) + .placeholder(CoreR.drawable.core_ic_default_profile_picture) .build(), contentDescription = stringResource( - id = org.openedx.core.R.string.core_accessibility_user_profile_image, + id = CoreR.string.core_accessibility_user_profile_image, thread.author ), modifier = Modifier @@ -209,7 +210,7 @@ fun CommentItem( } else if (comment.users?.get(comment.author)?.image?.hasImage == true) { comment.users[comment.author]?.image?.imageUrlFull } else { - org.openedx.core.R.drawable.core_ic_default_profile_picture + CoreR.drawable.core_ic_default_profile_picture } val reportText = if (comment.abuseFlagged) { @@ -260,11 +261,11 @@ fun CommentItem( AsyncImage( model = ImageRequest.Builder(LocalContext.current) .data(profileImageUrl) - .error(org.openedx.core.R.drawable.core_ic_default_profile_picture) - .placeholder(org.openedx.core.R.drawable.core_ic_default_profile_picture) + .error(CoreR.drawable.core_ic_default_profile_picture) + .placeholder(CoreR.drawable.core_ic_default_profile_picture) .build(), contentDescription = stringResource( - id = org.openedx.core.R.string.core_accessibility_user_profile_image, + id = CoreR.string.core_accessibility_user_profile_image, comment.author ), modifier = Modifier @@ -370,7 +371,7 @@ fun CommentMainItem( } else if (comment.users?.get(comment.author)?.image?.hasImage == true) { comment.users[comment.author]?.image?.imageUrlFull } else { - org.openedx.core.R.drawable.core_ic_default_profile_picture + CoreR.drawable.core_ic_default_profile_picture } val reportText = if (comment.abuseFlagged) { @@ -413,11 +414,11 @@ fun CommentMainItem( AsyncImage( model = ImageRequest.Builder(LocalContext.current) .data(profileImageUrl) - .error(org.openedx.core.R.drawable.core_ic_default_profile_picture) - .placeholder(org.openedx.core.R.drawable.core_ic_default_profile_picture) + .error(CoreR.drawable.core_ic_default_profile_picture) + .placeholder(CoreR.drawable.core_ic_default_profile_picture) .build(), contentDescription = stringResource( - id = org.openedx.core.R.string.core_accessibility_user_profile_image, + id = CoreR.string.core_accessibility_user_profile_image, comment.author ), modifier = Modifier @@ -502,7 +503,7 @@ fun ThreadItem( ) { val icon = when (thread.type) { DiscussionType.DISCUSSION -> painterResource(id = R.drawable.discussion_ic_discussion) - DiscussionType.QUESTION -> rememberVectorPainter(image = Icons.Outlined.HelpOutline) + DiscussionType.QUESTION -> rememberVectorPainter(image = Icons.AutoMirrored.Outlined.HelpOutline) } val textType = when (thread.type) { DiscussionType.DISCUSSION -> stringResource(id = R.string.discussion_discussion) diff --git a/gradle.properties b/gradle.properties index 022338b78..cf0008ddc 100644 --- a/gradle.properties +++ b/gradle.properties @@ -21,5 +21,4 @@ kotlin.code.style=official # resources declared in the library itself and none from the library's dependencies, # thereby reducing the size of the R class for that library android.nonTransitiveRClass=true -android.defaults.buildfeatures.buildconfig=true -android.nonFinalResIds=false \ No newline at end of file +android.nonFinalResIds=false 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 index d8af99f72..2d4b75dd2 100644 --- 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 @@ -31,10 +31,10 @@ 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.automirrored.filled.ArrowForwardIos +import androidx.compose.material.icons.automirrored.filled.ExitToApp +import androidx.compose.material.icons.automirrored.filled.OpenInNew 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 @@ -399,7 +399,7 @@ private fun LogoutButton(onClick: () -> Unit) { color = MaterialTheme.appColors.error ) Icon( - imageVector = Icons.Filled.ExitToApp, + imageVector = Icons.AutoMirrored.Filled.ExitToApp, contentDescription = null, tint = MaterialTheme.appColors.error ) @@ -510,9 +510,9 @@ private fun ProfileInfoItem( onClick: () -> Unit ) { val icon = if (external) { - Icons.Filled.OpenInNew + Icons.AutoMirrored.Filled.OpenInNew } else { - Icons.Filled.ArrowForwardIos + Icons.AutoMirrored.Filled.ArrowForwardIos } Row( Modifier From 4107ccd0ea7938c34f0416dba0f0170b9767386e Mon Sep 17 00:00:00 2001 From: droid Date: Thu, 28 Mar 2024 14:14:30 +0100 Subject: [PATCH 30/39] fix: bug when the image change icon was not clickable --- .../profile/presentation/edit/EditProfileFragment.kt | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) 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 c09294c5b..dcf8d4d35 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 @@ -650,7 +650,13 @@ private fun EditProfileScreen( .size(32.dp) .clip(CircleShape) .background(MaterialTheme.appColors.primary) - .padding(5.dp), + .padding(5.dp) + .clickable { + isOpenChangeImageDialogState = true + if (!uiState.account.isOlderThanMinAge()) { + openWarningMessageDialog = true + } + }, painter = painterResource(id = R.drawable.profile_ic_edit_image), contentDescription = null, tint = Color.White From 8f4b3b931b6685d1fa84981a605ded3adba702e2 Mon Sep 17 00:00:00 2001 From: Igor Date: Thu, 28 Mar 2024 20:49:35 +0100 Subject: [PATCH 31/39] fix: removed extra space on the course tab (#271) --- .../outline/CourseOutlineFragment.kt | 16 +++++++--------- .../openedx/course/presentation/ui/CourseUI.kt | 4 ++-- 2 files changed, 9 insertions(+), 11 deletions(-) diff --git a/course/src/main/java/org/openedx/course/presentation/outline/CourseOutlineFragment.kt b/course/src/main/java/org/openedx/course/presentation/outline/CourseOutlineFragment.kt index 64ed6758e..26b6384a6 100644 --- a/course/src/main/java/org/openedx/course/presentation/outline/CourseOutlineFragment.kt +++ b/course/src/main/java/org/openedx/course/presentation/outline/CourseOutlineFragment.kt @@ -377,7 +377,6 @@ internal fun CourseOutlineScreen( ) } } - item { Spacer(Modifier.height(28.dp)) } if (uiState.datesBannerInfo.isBannerAvailableForDashboard()) { item { Box( @@ -386,13 +385,11 @@ internal fun CourseOutlineScreen( ) { if (windowSize.isTablet) { CourseDatesBannerTablet( - modifier = Modifier.padding(bottom = 16.dp), banner = uiState.datesBannerInfo, resetDates = onResetDatesClick, ) } else { CourseDatesBanner( - modifier = Modifier.padding(bottom = 16.dp), banner = uiState.datesBannerInfo, resetDates = onResetDatesClick, ) @@ -405,11 +402,13 @@ internal fun CourseOutlineScreen( Box(listPadding) { if (windowSize.isTablet) { ResumeCourseTablet( + modifier = Modifier.padding(vertical = 16.dp), block = uiState.resumeComponent, onResumeClick = onResumeClick ) } else { ResumeCourse( + modifier = Modifier.padding(vertical = 16.dp), block = uiState.resumeComponent, onResumeClick = onResumeClick ) @@ -419,9 +418,6 @@ internal fun CourseOutlineScreen( } if (isCourseNestedListEnabled) { - item { - Spacer(Modifier.height(16.dp)) - } uiState.courseStructure.blockData.forEach { section -> val courseSubSections = uiState.courseSubSections[section.id] @@ -533,11 +529,12 @@ internal fun CourseOutlineScreen( @Composable private fun ResumeCourse( + modifier: Modifier = Modifier, block: Block, onResumeClick: (String) -> Unit, ) { Column( - modifier = Modifier.fillMaxWidth() + modifier = modifier.fillMaxWidth() ) { Text( text = stringResource(id = org.openedx.course.R.string.course_continue_with), @@ -584,11 +581,12 @@ private fun ResumeCourse( @Composable private fun ResumeCourseTablet( + modifier: Modifier = Modifier, block: Block, onResumeClick: (String) -> Unit, ) { Row( - modifier = Modifier.fillMaxWidth(), + modifier = modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween ) { Column( @@ -728,7 +726,7 @@ private fun CourseOutlineScreenTabletPreview() { @Composable private fun ResumeCoursePreview() { OpenEdXTheme { - ResumeCourse(mockChapterBlock) {} + ResumeCourse(block = mockChapterBlock) {} } } diff --git a/course/src/main/java/org/openedx/course/presentation/ui/CourseUI.kt b/course/src/main/java/org/openedx/course/presentation/ui/CourseUI.kt index 546348f3e..d2fadda7c 100644 --- a/course/src/main/java/org/openedx/course/presentation/ui/CourseUI.kt +++ b/course/src/main/java/org/openedx/course/presentation/ui/CourseUI.kt @@ -1058,7 +1058,7 @@ fun SubSectionUnitsList( @Composable fun CourseDatesBanner( - modifier: Modifier, + modifier: Modifier = Modifier, banner: CourseDatesBannerInfo, resetDates: () -> Unit, ) { @@ -1104,7 +1104,7 @@ fun CourseDatesBanner( @Composable fun CourseDatesBannerTablet( - modifier: Modifier, + modifier: Modifier = Modifier, banner: CourseDatesBannerInfo, resetDates: () -> Unit, ) { From e303397ad8b4482344d6cbb3fefffd960a63d388 Mon Sep 17 00:00:00 2001 From: Igor Date: Fri, 29 Mar 2024 10:09:45 +0100 Subject: [PATCH 32/39] fix: button name changed when trying to create a question (#272) --- .../threads/DiscussionAddThreadFragment.kt | 79 ++++++++++++++++--- discussion/src/main/res/values/strings.xml | 1 + 2 files changed, 69 insertions(+), 11 deletions(-) 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 afeffa652..416140f1e 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 @@ -1,5 +1,3 @@ -@file:OptIn(ExperimentalComposeUiApi::class) - package org.openedx.discussion.presentation.threads import android.content.res.Configuration.UI_MODE_NIGHT_NO @@ -7,17 +5,53 @@ 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.background +import androidx.compose.foundation.border +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.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.width +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material.* +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.Checkbox +import androidx.compose.material.CheckboxDefaults +import androidx.compose.material.CircularProgressIndicator +import androidx.compose.material.ExperimentalMaterialApi +import androidx.compose.material.Icon +import androidx.compose.material.MaterialTheme +import androidx.compose.material.ModalBottomSheetLayout +import androidx.compose.material.ModalBottomSheetValue +import androidx.compose.material.OutlinedTextField +import androidx.compose.material.Scaffold +import androidx.compose.material.Surface +import androidx.compose.material.Tab +import androidx.compose.material.TabRow +import androidx.compose.material.Text +import androidx.compose.material.TextFieldDefaults import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.ExpandMore -import androidx.compose.runtime.* +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.livedata.observeAsState +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.draw.clip import androidx.compose.ui.graphics.Color @@ -40,8 +74,23 @@ import kotlinx.coroutines.launch import org.koin.androidx.viewmodel.ext.android.viewModel import org.koin.core.parameter.parametersOf import org.openedx.core.UIMessage -import org.openedx.core.ui.* -import org.openedx.core.ui.theme.* +import org.openedx.core.ui.BackBtn +import org.openedx.core.ui.HandleUIMessage +import org.openedx.core.ui.OpenEdXButton +import org.openedx.core.ui.OpenEdXOutlinedTextField +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.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.openedx.core.ui.windowSizeValue import org.openedx.discussion.domain.model.DiscussionType import org.openedx.discussion.R as discussionR @@ -261,7 +310,11 @@ private fun DiscussionAddThreadScreen( modifier = Modifier .fillMaxWidth() .padding(horizontal = 48.dp), - text = stringResource(id = discussionR.string.discussion_create_post), + text = if (currentPage == 0) { + stringResource(id = discussionR.string.discussion_create_post) + } else { + stringResource(id = discussionR.string.discussion_create_question) + }, color = MaterialTheme.appColors.textPrimary, textAlign = TextAlign.Center, style = MaterialTheme.appTypography.titleMedium, @@ -386,7 +439,11 @@ private fun DiscussionAddThreadScreen( } else { OpenEdXButton( modifier = buttonWidth, - text = stringResource(id = discussionR.string.discussion_create_post), + text = if (currentPage == 0) { + stringResource(id = discussionR.string.discussion_create_post) + } else { + stringResource(id = discussionR.string.discussion_create_question) + }, onClick = { onPostDiscussionClick( discussionType, diff --git a/discussion/src/main/res/values/strings.xml b/discussion/src/main/res/values/strings.xml index cf50a9dbe..2527da01f 100644 --- a/discussion/src/main/res/values/strings.xml +++ b/discussion/src/main/res/values/strings.xml @@ -9,6 +9,7 @@ Most activity Most votes Create discussion + Create question Add a response Last post: %1$s Follow From cffbde3e35d506bd7eb030a5f7baf8d095cd0941 Mon Sep 17 00:00:00 2001 From: Omer Habib <30689349+omerhabib26@users.noreply.github.com> Date: Mon, 1 Apr 2024 15:05:36 +0500 Subject: [PATCH 33/39] fix: Analytics Improvements + Update test cases (#277) - Updated the analytics with iOS parity - Updated test cases --- .../course/presentation/CourseAnalytics.kt | 4 +- .../container/CourseContainerViewModelTest.kt | 14 ++--- .../detail/CourseDetailsViewModelTest.kt | 42 +++++++++++++-- .../outline/CourseOutlineViewModelTest.kt | 16 ++++-- .../unit/video/VideoUnitViewModelTest.kt | 35 ++++++++++-- .../unit/video/VideoViewModelTest.kt | 53 ++++++++++++++----- 6 files changed, 129 insertions(+), 35 deletions(-) diff --git a/course/src/main/java/org/openedx/course/presentation/CourseAnalytics.kt b/course/src/main/java/org/openedx/course/presentation/CourseAnalytics.kt index ce0dafef5..53291c460 100644 --- a/course/src/main/java/org/openedx/course/presentation/CourseAnalytics.kt +++ b/course/src/main/java/org/openedx/course/presentation/CourseAnalytics.kt @@ -139,7 +139,7 @@ enum class CourseAnalyticsEvent(val eventName: String, val biValue: String) { ), PLS_BANNER_VIEWED( "PLS:Banner Viewed", - "edx.bi.app.coursedates.pls_banner.viewed" + "edx.bi.app.dates.pls_banner.viewed" ), PLS_SHIFT_BUTTON_CLICKED( "PLS:Shift Button Clicked", @@ -147,7 +147,7 @@ enum class CourseAnalyticsEvent(val eventName: String, val biValue: String) { ), PLS_SHIFT_DATES( "PLS:Shift Dates", - "edx.bi.app.coursedates.pls_banner.shift_dates" + "edx.bi.app.dates.pls_banner.shift_dates" ), DATES_CALENDAR_SYNC_TOGGLE( "Dates:CalendarSync Toggle", 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 0e94b5cc3..0dc214f81 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 @@ -36,6 +36,7 @@ import org.openedx.core.system.notifier.CourseStructureUpdated import org.openedx.course.data.storage.CoursePreferences import org.openedx.course.domain.interactor.CourseInteractor import org.openedx.course.presentation.CourseAnalytics +import org.openedx.course.presentation.CourseAnalyticsEvent import org.openedx.course.presentation.calendarsync.CalendarManager import java.net.UnknownHostException import java.util.Date @@ -136,12 +137,12 @@ class CourseContainerViewModelTest { ) every { networkConnection.isOnline() } returns true coEvery { interactor.preloadCourseStructure(any()) } throws UnknownHostException() - every { analytics.logEvent(any(), any()) } returns Unit + every { analytics.logEvent(CourseAnalyticsEvent.DASHBOARD.eventName, any()) } returns Unit viewModel.preloadCourseStructure() advanceUntilIdle() coVerify(exactly = 1) { interactor.preloadCourseStructure(any()) } - verify(exactly = 1) { analytics.logEvent(any(), any()) } + verify(exactly = 1) { analytics.logEvent(CourseAnalyticsEvent.DASHBOARD.eventName, any()) } val message = viewModel.errorMessage.value assertEquals(noInternet, message) @@ -167,12 +168,12 @@ class CourseContainerViewModelTest { ) every { networkConnection.isOnline() } returns true coEvery { interactor.preloadCourseStructure(any()) } throws Exception() - every { analytics.logEvent(any(), any()) } returns Unit + every { analytics.logEvent(CourseAnalyticsEvent.DASHBOARD.eventName, any()) } returns Unit viewModel.preloadCourseStructure() advanceUntilIdle() coVerify(exactly = 1) { interactor.preloadCourseStructure(any()) } - verify(exactly = 1) { analytics.logEvent(any(), any()) } + verify(exactly = 1) { analytics.logEvent(CourseAnalyticsEvent.DASHBOARD.eventName, any()) } val message = viewModel.errorMessage.value assertEquals(somethingWrong, message) @@ -199,12 +200,12 @@ class CourseContainerViewModelTest { every { networkConnection.isOnline() } returns true coEvery { interactor.preloadCourseStructure(any()) } returns Unit every { interactor.getCourseStructureFromCache() } returns courseStructure - every { analytics.logEvent(any(), any()) } returns Unit + every { analytics.logEvent(CourseAnalyticsEvent.DASHBOARD.eventName, any()) } returns Unit viewModel.preloadCourseStructure() advanceUntilIdle() coVerify(exactly = 1) { interactor.preloadCourseStructure(any()) } - verify(exactly = 1) { analytics.logEvent(any(), any()) } + verify(exactly = 1) { analytics.logEvent(CourseAnalyticsEvent.DASHBOARD.eventName, any()) } assert(viewModel.errorMessage.value == null) assert(viewModel.showProgress.value == false) @@ -325,5 +326,4 @@ class CourseContainerViewModelTest { assert(viewModel.errorMessage.value == null) assert(viewModel.showProgress.value == false) } - } 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 458d7b7bf..c5dbd2696 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 @@ -33,6 +33,7 @@ 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 org.openedx.course.presentation.CourseAnalyticsEvent import java.net.UnknownHostException @OptIn(ExperimentalCoroutinesApi::class) @@ -239,14 +240,24 @@ class CourseDetailsViewModelTest { coEvery { notifier.send(CourseDashboardUpdate()) } returns Unit every { networkConnection.isOnline() } returns true coEvery { interactor.getCourseDetails(any()) } returns mockCourse - every { analytics.logEvent(any(), any()) } returns Unit + every { + analytics.logEvent( + CourseAnalyticsEvent.COURSE_ENROLL_CLICKED.eventName, + any() + ) + } returns Unit viewModel.enrollInACourse("", "") advanceUntilIdle() coVerify(exactly = 1) { interactor.enrollInACourse(any()) } - verify(exactly = 1) { analytics.logEvent(any(), any()) } + verify(exactly = 1) { + analytics.logEvent( + CourseAnalyticsEvent.COURSE_ENROLL_CLICKED.eventName, + any() + ) + } val message = viewModel.uiMessage.value as? UIMessage.SnackBarMessage assertEquals(somethingWrong, message?.message) @@ -267,7 +278,18 @@ class CourseDetailsViewModelTest { ) every { config.isPreLoginExperienceEnabled() } returns false every { preferencesManager.user } returns null - every { analytics.logEvent(any(), any()) } returns Unit + every { + analytics.logEvent( + CourseAnalyticsEvent.COURSE_ENROLL_CLICKED.eventName, + any() + ) + } returns Unit + every { + analytics.logEvent( + CourseAnalyticsEvent.COURSE_ENROLL_SUCCESS.eventName, + any() + ) + } returns Unit coEvery { interactor.enrollInACourse(any()) } returns Unit coEvery { notifier.send(CourseDashboardUpdate()) } returns Unit every { networkConnection.isOnline() } returns true @@ -279,7 +301,18 @@ class CourseDetailsViewModelTest { advanceUntilIdle() coVerify(exactly = 1) { interactor.enrollInACourse(any()) } - verify(exactly = 2) { analytics.logEvent(any(), any()) } + verify(exactly = 1) { + analytics.logEvent( + CourseAnalyticsEvent.COURSE_ENROLL_CLICKED.eventName, + any() + ) + } + verify(exactly = 1) { + analytics.logEvent( + CourseAnalyticsEvent.COURSE_ENROLL_SUCCESS.eventName, + any() + ) + } assert(viewModel.uiMessage.value == null) assert(viewModel.uiState.value is CourseDetailsUIState.CourseData) @@ -318,5 +351,4 @@ class CourseDetailsViewModelTest { val count = overview.contains("black") assert(!count) } - } 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 e72101887..174e8ea4f 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 @@ -47,6 +47,7 @@ import org.openedx.core.module.db.DownloadModelEntity import org.openedx.core.module.db.DownloadedState import org.openedx.core.module.db.FileType import org.openedx.core.presentation.CoreAnalytics +import org.openedx.core.presentation.CoreAnalyticsEvent import org.openedx.core.system.ResourceManager import org.openedx.core.system.connection.NetworkConnection import org.openedx.core.system.notifier.CourseNotifier @@ -439,7 +440,12 @@ class CourseOutlineViewModelTest { every { interactor.getCourseStructureFromCache() } returns courseStructure every { networkConnection.isWifiConnected() } returns true every { networkConnection.isOnline() } returns true - every { coreAnalytics.logEvent(any(), any()) } returns Unit + every { + coreAnalytics.logEvent( + CoreAnalyticsEvent.VIDEO_DOWNLOAD_SUBSECTION.eventName, + any() + ) + } returns Unit coEvery { workerController.saveModels(any()) } returns Unit coEvery { interactor.getCourseStatus(any()) } returns CourseComponentStatus("id") coEvery { downloadDao.readAllData() } returns flow { emit(emptyList()) } @@ -461,7 +467,12 @@ class CourseOutlineViewModelTest { viewModel.saveDownloadModels("", "") advanceUntilIdle() - verify(exactly = 1) { coreAnalytics.logEvent(any(), any()) } + verify(exactly = 1) { + coreAnalytics.logEvent( + CoreAnalyticsEvent.VIDEO_DOWNLOAD_SUBSECTION.eventName, + any() + ) + } assert(viewModel.uiMessage.value == null) } @@ -530,5 +541,4 @@ class CourseOutlineViewModelTest { assert(viewModel.uiMessage.value != null) assert(!viewModel.hasInternetConnection) } - } 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 498094d03..4270dba82 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 @@ -12,7 +12,11 @@ import io.mockk.verify import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.flow -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.Before import org.junit.Rule @@ -24,6 +28,7 @@ import org.openedx.core.system.notifier.CourseNotifier import org.openedx.core.system.notifier.CourseVideoPositionChanged import org.openedx.course.data.repository.CourseRepository import org.openedx.course.presentation.CourseAnalytics +import org.openedx.course.presentation.CourseAnalyticsEvent @OptIn(ExperimentalCoroutinesApi::class) class VideoUnitViewModelTest { @@ -66,7 +71,12 @@ class VideoUnitViewModelTest { any() ) } throws Exception() - every { courseAnalytics.logEvent(any(), any()) } returns Unit + every { + courseAnalytics.logEvent( + CourseAnalyticsEvent.VIDEO_COMPLETED.eventName, + any() + ) + } returns Unit viewModel.markBlockCompleted("", "") advanceUntilIdle() @@ -76,7 +86,12 @@ class VideoUnitViewModelTest { any() ) } - verify(exactly = 1) { courseAnalytics.logEvent(any(), any()) } + verify(exactly = 1) { + courseAnalytics.logEvent( + CourseAnalyticsEvent.VIDEO_COMPLETED.eventName, + any() + ) + } } @Test @@ -95,7 +110,12 @@ class VideoUnitViewModelTest { any() ) } returns Unit - every { courseAnalytics.logEvent(any(), any()) } returns Unit + every { + courseAnalytics.logEvent( + CourseAnalyticsEvent.VIDEO_COMPLETED.eventName, + any() + ) + } returns Unit viewModel.markBlockCompleted("", "") advanceUntilIdle() @@ -105,7 +125,12 @@ class VideoUnitViewModelTest { any() ) } - verify(exactly = 1) { courseAnalytics.logEvent(any(), any()) } + verify(exactly = 1) { + courseAnalytics.logEvent( + CourseAnalyticsEvent.VIDEO_COMPLETED.eventName, + any() + ) + } } @Test 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 278e24380..3f476fe29 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 @@ -1,9 +1,6 @@ package org.openedx.course.presentation.unit.video import androidx.arch.core.executor.testing.InstantTaskExecutorRule -import org.openedx.core.system.notifier.CourseNotifier -import org.openedx.core.system.notifier.CourseVideoPositionChanged -import org.openedx.course.data.repository.CourseRepository import io.mockk.coEvery import io.mockk.coVerify import io.mockk.every @@ -11,14 +8,22 @@ import io.mockk.mockk import io.mockk.verify import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi -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.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.system.notifier.CourseNotifier +import org.openedx.core.system.notifier.CourseVideoPositionChanged +import org.openedx.course.data.repository.CourseRepository import org.openedx.course.presentation.CourseAnalytics +import org.openedx.course.presentation.CourseAnalyticsEvent @OptIn(ExperimentalCoroutinesApi::class) class VideoViewModelTest { @@ -45,7 +50,8 @@ class VideoViewModelTest { @Test fun `sendTime test`() = runTest { - val viewModel = VideoViewModel("", courseRepository, notifier, preferenceManager, courseAnalytics) + val viewModel = + VideoViewModel("", courseRepository, notifier, preferenceManager, courseAnalytics) coEvery { notifier.send(CourseVideoPositionChanged("", 0, false)) } returns Unit viewModel.sendTime() advanceUntilIdle() @@ -55,14 +61,20 @@ class VideoViewModelTest { @Test fun `markBlockCompleted exception`() = runTest { - val viewModel = VideoViewModel("", courseRepository, notifier, preferenceManager, courseAnalytics) + val viewModel = + VideoViewModel("", courseRepository, notifier, preferenceManager, courseAnalytics) coEvery { courseRepository.markBlocksCompletion( any(), any() ) } throws Exception() - every { courseAnalytics.logEvent(any(), any()) } returns Unit + every { + courseAnalytics.logEvent( + CourseAnalyticsEvent.VIDEO_COMPLETED.eventName, + any() + ) + } returns Unit viewModel.markBlockCompleted("", "") advanceUntilIdle() @@ -72,20 +84,31 @@ class VideoViewModelTest { any() ) } - verify(exactly = 1) { courseAnalytics.logEvent(any(), any()) } + verify(exactly = 1) { + courseAnalytics.logEvent( + CourseAnalyticsEvent.VIDEO_COMPLETED.eventName, + any() + ) + } } @Test fun `markBlockCompleted success`() = runTest { - val viewModel = VideoViewModel("", courseRepository, notifier, preferenceManager, courseAnalytics) + val viewModel = + VideoViewModel("", courseRepository, notifier, preferenceManager, courseAnalytics) coEvery { courseRepository.markBlocksCompletion( any(), any() ) } returns Unit - every { courseAnalytics.logEvent(any(), any()) } returns Unit + every { + courseAnalytics.logEvent( + CourseAnalyticsEvent.VIDEO_COMPLETED.eventName, + any() + ) + } returns Unit viewModel.markBlockCompleted("", "") advanceUntilIdle() @@ -95,7 +118,11 @@ class VideoViewModelTest { any() ) } - verify(exactly = 1) { courseAnalytics.logEvent(any(), any()) } + verify(exactly = 1) { + courseAnalytics.logEvent( + CourseAnalyticsEvent.VIDEO_COMPLETED.eventName, + any() + ) + } } - -} \ No newline at end of file +} From 6dd2adf2886e973af7dff7bae3fdc7f5072ffc94 Mon Sep 17 00:00:00 2001 From: Hamza Israr <71447999+HamzaIsrar12@users.noreply.github.com> Date: Sun, 7 Apr 2024 22:56:45 +0500 Subject: [PATCH 34/39] refactor: Transfer Discovery Related Screens to Discovery Module (#279) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * refactor: Transfer Discovery Details Page to Discovery Module Initially integrated within the course module, both pages have been strategically reassigned to the Discovery module, reflecting a refined organizational structure. - Relocated screens to ensure alignment with broader scope - Migrated presentation, domain, and data layers - Transferred models, database, repository and interactions - Included analytics for a comprehensive transition * refactor: Transfer Programs Screen to Discovery Module Initially integrated within the dashboard module, both pages have been strategically reassigned to the Discovery module, reflecting a refined organizational structure. - Relocated screens to ensure alignment with broader scope - Migrated presentation, domain, and data layers - Transferred models, database, repository and interactions - Included analytics for a comprehensive transition * refactor: Move Models and UI Components to Discovery Module We've streamlined our project by relocating models and UI components previously stored in the Core module to the Discovery module. These elements were previously shared across multiple modules—course, dashboard, and discovery. --- .../java/org/openedx/app/AnalyticsManager.kt | 2 +- .../main/java/org/openedx/app/AppRouter.kt | 8 +- .../main/java/org/openedx/app/MainFragment.kt | 4 +- .../java/org/openedx/app/MainViewModel.kt | 8 +- .../main/java/org/openedx/app/di/AppModule.kt | 10 +- .../org/openedx/app/di/NetworkingModule.kt | 2 + .../java/org/openedx/app/di/ScreenModule.kt | 12 +- .../java/org/openedx/app/room/AppDatabase.kt | 2 +- .../org/openedx/core/data/api/CourseApi.kt | 42 ++-- .../openedx/core/domain/model/CourseList.kt | 6 - .../interfaces/EnrollInCourseInteractor.kt | 5 - .../core/presentation/CoreAnalytics.kt | 3 - .../system/notifier/CourseDashboardUpdate.kt | 2 +- .../core/system/notifier/CourseNotifier.kt | 1 - .../core/system/notifier/DiscoveryEvent.kt | 3 + .../core/system/notifier/DiscoveryNotifier.kt | 15 ++ .../system/notifier/NavigationToDiscovery.kt | 3 + .../java/org/openedx/core/ui/ComposeCommon.kt | 77 ------ core/src/main/res/values-uk/strings.xml | 1 + core/src/main/res/values/strings.xml | 1 + .../data/repository/CourseRepository.kt | 28 --- .../openedx/course/data/storage/CourseDao.kt | 8 - .../domain/interactor/CourseInteractor.kt | 14 +- .../course/presentation/CourseAnalytics.kt | 17 -- .../course/presentation/CourseRouter.kt | 16 -- .../course/presentation/ui/CourseUI.kt | 69 +----- course/src/main/res/values-uk/strings.xml | 6 - course/src/main/res/values/strings.xml | 9 - .../presentation/MyCoursesScreenTest.kt | 4 +- .../dashboard/notifier/DashboardEvent.kt | 6 - .../dashboard/notifier/DashboardNotifier.kt | 19 -- .../{dashboard => }/DashboardAnalytics.kt | 2 +- .../{dashboard => }/DashboardFragment.kt | 3 +- .../dashboard/presentation/DashboardRouter.kt | 8 - .../{dashboard => }/DashboardUIState.kt | 4 +- .../{dashboard => }/DashboardViewModel.kt | 21 +- dashboard/src/main/res/values/strings.xml | 2 - .../presentation/DashboardViewModelTest.kt | 127 ++++++++-- .../discovery/data/api/DiscoveryApi.kt | 38 +++ .../discovery}/data/model/CourseDetails.kt | 6 +- .../discovery}/data/model/CourseList.kt | 5 +- .../data/model/room/CourseEntity.kt | 10 +- .../data/repository/DiscoveryRepository.kt | 36 ++- .../discovery/data/storage/DiscoveryDao.kt | 13 +- .../domain/interactor/DiscoveryInteractor.kt | 14 +- .../openedx/discovery}/domain/model/Course.kt | 7 +- .../discovery/domain/model/CourseList.kt | 8 + .../presentation/DiscoveryAnalytics.kt | 15 ++ .../discovery/presentation/DiscoveryRouter.kt | 8 + .../presentation/DiscoveryUIState.kt | 6 +- .../presentation/NativeDiscoveryFragment.kt | 4 +- .../presentation/NativeDiscoveryViewModel.kt | 2 +- .../presentation/WebViewDiscoveryFragment.kt | 7 +- .../presentation/catalog/CatalogWebView.kt | 5 +- .../catalog}/DefaultWebViewClient.kt | 3 +- .../presentation/catalog/WebViewLink.kt | 4 +- .../detail/CourseDetailsFragment.kt | 55 +++-- .../detail/CourseDetailsUIState.kt | 6 +- .../detail/CourseDetailsViewModel.kt | 34 +-- .../presentation/info/CourseInfoFragment.kt | 16 +- .../presentation/info/CourseInfoUIState.kt | 2 +- .../presentation/info/CourseInfoViewModel.kt | 55 +++-- .../presentation/program/ProgramFragment.kt | 20 +- .../presentation/program/ProgramUIState.kt | 6 +- .../presentation/program/ProgramViewModel.kt | 25 +- .../search/CourseSearchFragment.kt | 6 +- .../search/CourseSearchUIState.kt | 6 +- .../search/CourseSearchViewModel.kt | 4 +- .../discovery/presentation/ui/DiscoveryUI.kt | 230 ++++++++++++++++++ .../main/res/drawable/discovery_ic_play.xml | 0 discovery/src/main/res/values-uk/strings.xml | 8 +- discovery/src/main/res/values/strings.xml | 11 + .../NativeDiscoveryViewModelTest.kt | 100 +++++++- .../detail/CourseDetailsViewModelTest.kt | 30 +-- .../search/CourseSearchViewModelTest.kt | 6 +- 75 files changed, 804 insertions(+), 577 deletions(-) delete mode 100644 core/src/main/java/org/openedx/core/domain/model/CourseList.kt delete mode 100644 core/src/main/java/org/openedx/core/interfaces/EnrollInCourseInteractor.kt create mode 100644 core/src/main/java/org/openedx/core/system/notifier/DiscoveryEvent.kt create mode 100644 core/src/main/java/org/openedx/core/system/notifier/DiscoveryNotifier.kt create mode 100644 core/src/main/java/org/openedx/core/system/notifier/NavigationToDiscovery.kt delete mode 100644 dashboard/src/main/java/org/openedx/dashboard/notifier/DashboardEvent.kt delete mode 100644 dashboard/src/main/java/org/openedx/dashboard/notifier/DashboardNotifier.kt rename dashboard/src/main/java/org/openedx/dashboard/presentation/{dashboard => }/DashboardAnalytics.kt (67%) rename dashboard/src/main/java/org/openedx/dashboard/presentation/{dashboard => }/DashboardFragment.kt (99%) rename dashboard/src/main/java/org/openedx/dashboard/presentation/{dashboard => }/DashboardUIState.kt (81%) rename dashboard/src/main/java/org/openedx/dashboard/presentation/{dashboard => }/DashboardViewModel.kt (89%) create mode 100644 discovery/src/main/java/org/openedx/discovery/data/api/DiscoveryApi.kt rename {core/src/main/java/org/openedx/core => discovery/src/main/java/org/openedx/discovery}/data/model/CourseDetails.kt (94%) rename {core/src/main/java/org/openedx/core => discovery/src/main/java/org/openedx/discovery}/data/model/CourseList.kt (70%) rename {core/src/main/java/org/openedx/core => discovery/src/main/java/org/openedx/discovery}/data/model/room/CourseEntity.kt (94%) rename {core/src/main/java/org/openedx/core => discovery/src/main/java/org/openedx/discovery}/domain/model/Course.kt (83%) create mode 100644 discovery/src/main/java/org/openedx/discovery/domain/model/CourseList.kt rename {core/src/main/java/org/openedx/core => discovery/src/main/java/org/openedx/discovery}/presentation/catalog/CatalogWebView.kt (95%) rename {core/src/main/java/org/openedx/core/system => discovery/src/main/java/org/openedx/discovery/presentation/catalog}/DefaultWebViewClient.kt (96%) rename {core/src/main/java/org/openedx/core => discovery/src/main/java/org/openedx/discovery}/presentation/catalog/WebViewLink.kt (90%) rename {course/src/main/java/org/openedx/course => discovery/src/main/java/org/openedx/discovery}/presentation/detail/CourseDetailsFragment.kt (94%) rename {course/src/main/java/org/openedx/course => discovery/src/main/java/org/openedx/discovery}/presentation/detail/CourseDetailsUIState.kt (51%) rename {course/src/main/java/org/openedx/course => discovery/src/main/java/org/openedx/discovery}/presentation/detail/CourseDetailsViewModel.kt (82%) rename {course/src/main/java/org/openedx/course => discovery/src/main/java/org/openedx/discovery}/presentation/info/CourseInfoFragment.kt (96%) rename {course/src/main/java/org/openedx/course => discovery/src/main/java/org/openedx/discovery}/presentation/info/CourseInfoUIState.kt (83%) rename {course/src/main/java/org/openedx/course => discovery/src/main/java/org/openedx/discovery}/presentation/info/CourseInfoViewModel.kt (74%) rename {dashboard/src/main/java/org/openedx/dashboard => discovery/src/main/java/org/openedx/discovery}/presentation/program/ProgramFragment.kt (95%) rename {dashboard/src/main/java/org/openedx/dashboard => discovery/src/main/java/org/openedx/discovery}/presentation/program/ProgramUIState.kt (62%) rename {dashboard/src/main/java/org/openedx/dashboard => discovery/src/main/java/org/openedx/discovery}/presentation/program/ProgramViewModel.kt (80%) create mode 100644 discovery/src/main/java/org/openedx/discovery/presentation/ui/DiscoveryUI.kt rename course/src/main/res/drawable/course_ic_play.xml => discovery/src/main/res/drawable/discovery_ic_play.xml (100%) rename {course/src/test/java/org/openedx/course => discovery/src/test/java/org/openedx/discovery}/presentation/detail/CourseDetailsViewModelTest.kt (91%) diff --git a/app/src/main/java/org/openedx/app/AnalyticsManager.kt b/app/src/main/java/org/openedx/app/AnalyticsManager.kt index b8b6dbeff..356a23459 100644 --- a/app/src/main/java/org/openedx/app/AnalyticsManager.kt +++ b/app/src/main/java/org/openedx/app/AnalyticsManager.kt @@ -9,7 +9,7 @@ import org.openedx.core.config.Config import org.openedx.core.presentation.CoreAnalytics import org.openedx.core.presentation.dialog.appreview.AppReviewAnalytics import org.openedx.course.presentation.CourseAnalytics -import org.openedx.dashboard.presentation.dashboard.DashboardAnalytics +import org.openedx.dashboard.presentation.DashboardAnalytics import org.openedx.discovery.presentation.DiscoveryAnalytics import org.openedx.discussion.presentation.DiscussionAnalytics import org.openedx.profile.presentation.ProfileAnalytics diff --git a/app/src/main/java/org/openedx/app/AppRouter.kt b/app/src/main/java/org/openedx/app/AppRouter.kt index 1e6875b4e..474f4a8e9 100644 --- a/app/src/main/java/org/openedx/app/AppRouter.kt +++ b/app/src/main/java/org/openedx/app/AppRouter.kt @@ -18,20 +18,20 @@ import org.openedx.core.presentation.settings.VideoQualityType 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.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.course.settings.download.DownloadQueueFragment 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.WebViewDiscoveryFragment +import org.openedx.discovery.presentation.detail.CourseDetailsFragment +import org.openedx.discovery.presentation.info.CourseInfoFragment +import org.openedx.discovery.presentation.program.ProgramFragment import org.openedx.discovery.presentation.search.CourseSearchFragment import org.openedx.discussion.domain.model.DiscussionComment import org.openedx.discussion.domain.model.Thread @@ -143,7 +143,7 @@ class AppRouter : AuthRouter, DiscoveryRouter, DashboardRouter, CourseRouter, Di ) } - override fun navigateToProgramInfo(fm: FragmentManager, pathId: String) { + override fun navigateToEnrolledProgramInfo(fm: FragmentManager, pathId: String) { replaceFragmentWithBackStack(fm, ProgramFragment.newInstance(pathId)) } diff --git a/app/src/main/java/org/openedx/app/MainFragment.kt b/app/src/main/java/org/openedx/app/MainFragment.kt index b656d3b46..a798c4a3f 100644 --- a/app/src/main/java/org/openedx/app/MainFragment.kt +++ b/app/src/main/java/org/openedx/app/MainFragment.kt @@ -16,10 +16,10 @@ import org.openedx.app.databinding.FragmentMainBinding import org.openedx.core.config.Config 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.dashboard.presentation.DashboardFragment import org.openedx.discovery.presentation.DiscoveryNavigator import org.openedx.discovery.presentation.DiscoveryRouter +import org.openedx.discovery.presentation.program.ProgramFragment import org.openedx.profile.presentation.profile.ProfileFragment class MainFragment : Fragment(R.layout.fragment_main) { diff --git a/app/src/main/java/org/openedx/app/MainViewModel.kt b/app/src/main/java/org/openedx/app/MainViewModel.kt index 1da2f64f5..6a30533ea 100644 --- a/app/src/main/java/org/openedx/app/MainViewModel.kt +++ b/app/src/main/java/org/openedx/app/MainViewModel.kt @@ -12,12 +12,12 @@ 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 +import org.openedx.core.system.notifier.DiscoveryNotifier +import org.openedx.core.system.notifier.NavigationToDiscovery class MainViewModel( private val config: Config, - private val notifier: DashboardNotifier, + private val notifier: DiscoveryNotifier, private val analytics: AppAnalytics, ) : BaseViewModel() { @@ -36,7 +36,7 @@ class MainViewModel( override fun onCreate(owner: LifecycleOwner) { super.onCreate(owner) notifier.notifier.onEach { - if (it is DashboardEvent.NavigationToDiscovery) { + if (it is NavigationToDiscovery) { _navigateToDiscovery.emit(true) } }.distinctUntilChanged().launchIn(viewModelScope) 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 2952e16c8..dc0a70335 100644 --- a/app/src/main/java/org/openedx/app/di/AppModule.kt +++ b/app/src/main/java/org/openedx/app/di/AppModule.kt @@ -29,7 +29,6 @@ import org.openedx.core.config.Config import org.openedx.core.data.model.CourseEnrollments 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 @@ -44,16 +43,15 @@ import org.openedx.core.system.ResourceManager import org.openedx.core.system.connection.NetworkConnection import org.openedx.core.system.notifier.AppUpgradeNotifier import org.openedx.core.system.notifier.CourseNotifier +import org.openedx.core.system.notifier.DiscoveryNotifier import org.openedx.core.system.notifier.DownloadNotifier import org.openedx.core.system.notifier.VideoNotifier import org.openedx.course.data.storage.CoursePreferences -import org.openedx.course.domain.interactor.CourseInteractor import org.openedx.course.presentation.CourseAnalytics import org.openedx.course.presentation.CourseRouter import org.openedx.course.presentation.calendarsync.CalendarManager -import org.openedx.dashboard.notifier.DashboardNotifier +import org.openedx.dashboard.presentation.DashboardAnalytics import org.openedx.dashboard.presentation.DashboardRouter -import org.openedx.dashboard.presentation.dashboard.DashboardAnalytics import org.openedx.discovery.presentation.DiscoveryAnalytics import org.openedx.discovery.presentation.DiscoveryRouter import org.openedx.discussion.presentation.DiscussionAnalytics @@ -95,8 +93,8 @@ val appModule = module { single { ProfileNotifier() } single { AppUpgradeNotifier() } single { DownloadNotifier() } - single { DashboardNotifier() } single { VideoNotifier() } + single { DiscoveryNotifier() } single { AppRouter() } single { get() } @@ -180,6 +178,4 @@ val appModule = module { 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 b74deefbb..c281d0465 100644 --- a/app/src/main/java/org/openedx/app/di/NetworkingModule.kt +++ b/app/src/main/java/org/openedx/app/di/NetworkingModule.kt @@ -12,6 +12,7 @@ import org.openedx.core.BuildConfig import org.openedx.core.config.Config import org.openedx.core.data.api.CookiesApi import org.openedx.core.data.api.CourseApi +import org.openedx.discovery.data.api.DiscoveryApi import org.openedx.discussion.data.api.DiscussionApi import org.openedx.profile.data.api.ProfileApi import retrofit2.Retrofit @@ -51,6 +52,7 @@ val networkingModule = module { single { provideApi(get()) } single { provideApi(get()) } single { provideApi(get()) } + single { provideApi(get()) } } 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 4b5bf341f..51e3a28f1 100644 --- a/app/src/main/java/org/openedx/app/di/ScreenModule.kt +++ b/app/src/main/java/org/openedx/app/di/ScreenModule.kt @@ -18,9 +18,7 @@ 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.course.presentation.section.CourseSectionViewModel import org.openedx.course.presentation.unit.container.CourseUnitContainerViewModel @@ -33,12 +31,14 @@ import org.openedx.course.presentation.videos.CourseVideoViewModel import org.openedx.course.settings.download.DownloadQueueViewModel import org.openedx.dashboard.data.repository.DashboardRepository import org.openedx.dashboard.domain.interactor.DashboardInteractor -import org.openedx.dashboard.presentation.dashboard.DashboardViewModel -import org.openedx.dashboard.presentation.program.ProgramViewModel +import org.openedx.dashboard.presentation.DashboardViewModel import org.openedx.discovery.data.repository.DiscoveryRepository import org.openedx.discovery.domain.interactor.DiscoveryInteractor import org.openedx.discovery.presentation.NativeDiscoveryViewModel import org.openedx.discovery.presentation.WebViewDiscoveryViewModel +import org.openedx.discovery.presentation.detail.CourseDetailsViewModel +import org.openedx.discovery.presentation.info.CourseInfoViewModel +import org.openedx.discovery.presentation.program.ProgramViewModel import org.openedx.discovery.presentation.search.CourseSearchViewModel import org.openedx.discussion.data.repository.DiscussionRepository import org.openedx.discussion.domain.interactor.DiscussionInteractor @@ -114,9 +114,9 @@ val screenModule = module { factory { DashboardRepository(get(), get(), get()) } factory { DashboardInteractor(get()) } - viewModel { DashboardViewModel(get(), get(), get(), get(), get(), get(), get(), get()) } + viewModel { DashboardViewModel(get(), get(), get(), get(), get(), get(), get()) } - factory { DiscoveryRepository(get(), get()) } + factory { DiscoveryRepository(get(), get(), get()) } factory { DiscoveryInteractor(get()) } viewModel { NativeDiscoveryViewModel(get(), get(), get(), get(), get(), get(), get()) } viewModel { (querySearch: String) -> diff --git a/app/src/main/java/org/openedx/app/room/AppDatabase.kt b/app/src/main/java/org/openedx/app/room/AppDatabase.kt index f28bd8192..be320bae7 100644 --- a/app/src/main/java/org/openedx/app/room/AppDatabase.kt +++ b/app/src/main/java/org/openedx/app/room/AppDatabase.kt @@ -3,7 +3,6 @@ package org.openedx.app.room import androidx.room.Database import androidx.room.RoomDatabase import androidx.room.TypeConverters -import org.openedx.core.data.model.room.CourseEntity import org.openedx.core.data.model.room.CourseStructureEntity import org.openedx.core.data.model.room.discovery.EnrolledCourseEntity import org.openedx.core.module.db.DownloadDao @@ -12,6 +11,7 @@ import org.openedx.course.data.storage.CourseConverter import org.openedx.course.data.storage.CourseDao import org.openedx.dashboard.data.DashboardDao import org.openedx.discovery.data.converter.DiscoveryConverter +import org.openedx.discovery.data.model.room.CourseEntity import org.openedx.discovery.data.storage.DiscoveryDao const val DATABASE_VERSION = 1 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 64e749a26..4a19c383d 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,8 +1,20 @@ package org.openedx.core.data.api -import okhttp3.ResponseBody -import org.openedx.core.data.model.* -import retrofit2.http.* +import org.openedx.core.data.model.AnnouncementModel +import org.openedx.core.data.model.BlocksCompletionBody +import org.openedx.core.data.model.CourseComponentStatus +import org.openedx.core.data.model.CourseDates +import org.openedx.core.data.model.CourseDatesBannerInfo +import org.openedx.core.data.model.CourseEnrollments +import org.openedx.core.data.model.CourseStructureModel +import org.openedx.core.data.model.HandoutsModel +import org.openedx.core.data.model.ResetCourseDates +import retrofit2.http.Body +import retrofit2.http.GET +import retrofit2.http.Header +import retrofit2.http.POST +import retrofit2.http.Path +import retrofit2.http.Query interface CourseApi { @@ -14,27 +26,6 @@ interface CourseApi { @Query("page") page: Int ): CourseEnrollments - @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( - "enroll", - "see_in_catalog", - "see_about_page" - ) - ): CourseList - - @GET("/api/courses/v1/courses/{course_id}") - suspend fun getCourseDetail( - @Path("course_id") courseId: String?, - @Query("username") username: String? = null - ): CourseDetails - @GET( "/api/mobile/{api_version}/course_info/blocks/?" + "depth=all&" + @@ -50,9 +41,6 @@ interface CourseApi { @Query("course_id") courseId: String, ): CourseStructureModel - @POST("/api/enrollment/v1/enrollment") - suspend fun enrollInACourse(@Body enrollBody: EnrollBody): ResponseBody - @GET("/api/mobile/v1/users/{username}/course_status_info/{course_id}") suspend fun getCourseStatus( @Path("username") username: String, diff --git a/core/src/main/java/org/openedx/core/domain/model/CourseList.kt b/core/src/main/java/org/openedx/core/domain/model/CourseList.kt deleted file mode 100644 index 6c38ef924..000000000 --- a/core/src/main/java/org/openedx/core/domain/model/CourseList.kt +++ /dev/null @@ -1,6 +0,0 @@ -package org.openedx.core.domain.model - -data class CourseList( - val pagination: Pagination, - val results: List, -) \ No newline at end of file diff --git a/core/src/main/java/org/openedx/core/interfaces/EnrollInCourseInteractor.kt b/core/src/main/java/org/openedx/core/interfaces/EnrollInCourseInteractor.kt deleted file mode 100644 index 5c82de1f5..000000000 --- a/core/src/main/java/org/openedx/core/interfaces/EnrollInCourseInteractor.kt +++ /dev/null @@ -1,5 +0,0 @@ -package org.openedx.core.interfaces - -interface EnrollInCourseInteractor { - suspend fun enrollInACourse(id: String) -} diff --git a/core/src/main/java/org/openedx/core/presentation/CoreAnalytics.kt b/core/src/main/java/org/openedx/core/presentation/CoreAnalytics.kt index 49946903a..ed9ebd853 100644 --- a/core/src/main/java/org/openedx/core/presentation/CoreAnalytics.kt +++ b/core/src/main/java/org/openedx/core/presentation/CoreAnalytics.kt @@ -56,8 +56,5 @@ enum class CoreAnalyticsKey(val key: String) { } enum class CoreAnalyticsScreen(val screenName: String) { - DISCOVERY("Discovery"), - PROGRAM("Program"), - COURSE_INFO("Course Info"), COURSE_DATES("Course Dates"), } diff --git a/core/src/main/java/org/openedx/core/system/notifier/CourseDashboardUpdate.kt b/core/src/main/java/org/openedx/core/system/notifier/CourseDashboardUpdate.kt index 289b9b8c5..d2ac6c645 100644 --- a/core/src/main/java/org/openedx/core/system/notifier/CourseDashboardUpdate.kt +++ b/core/src/main/java/org/openedx/core/system/notifier/CourseDashboardUpdate.kt @@ -1,3 +1,3 @@ package org.openedx.core.system.notifier -class CourseDashboardUpdate : CourseEvent \ No newline at end of file +class CourseDashboardUpdate : DiscoveryEvent 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 ddd338540..455d6e53c 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 @@ -12,7 +12,6 @@ class CourseNotifier { suspend fun send(event: CourseVideoPositionChanged) = channel.emit(event) suspend fun send(event: CourseStructureUpdated) = channel.emit(event) - 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) diff --git a/core/src/main/java/org/openedx/core/system/notifier/DiscoveryEvent.kt b/core/src/main/java/org/openedx/core/system/notifier/DiscoveryEvent.kt new file mode 100644 index 000000000..bf01bae2c --- /dev/null +++ b/core/src/main/java/org/openedx/core/system/notifier/DiscoveryEvent.kt @@ -0,0 +1,3 @@ +package org.openedx.core.system.notifier + +interface DiscoveryEvent diff --git a/core/src/main/java/org/openedx/core/system/notifier/DiscoveryNotifier.kt b/core/src/main/java/org/openedx/core/system/notifier/DiscoveryNotifier.kt new file mode 100644 index 000000000..ef36b8daf --- /dev/null +++ b/core/src/main/java/org/openedx/core/system/notifier/DiscoveryNotifier.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 DiscoveryNotifier { + + private val channel = MutableSharedFlow(replay = 0, extraBufferCapacity = 0) + + val notifier: Flow = channel.asSharedFlow() + + suspend fun send(event: CourseDashboardUpdate) = channel.emit(event) + suspend fun send(event: NavigationToDiscovery) = channel.emit(event) +} diff --git a/core/src/main/java/org/openedx/core/system/notifier/NavigationToDiscovery.kt b/core/src/main/java/org/openedx/core/system/notifier/NavigationToDiscovery.kt new file mode 100644 index 000000000..f4bfbe19f --- /dev/null +++ b/core/src/main/java/org/openedx/core/system/notifier/NavigationToDiscovery.kt @@ -0,0 +1,3 @@ +package org.openedx.core.system.notifier + +class NavigationToDiscovery : DiscoveryEvent 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 0ba05a5e4..8e71eebcd 100644 --- a/core/src/main/java/org/openedx/core/ui/ComposeCommon.kt +++ b/core/src/main/java/org/openedx/core/ui/ComposeCommon.kt @@ -34,7 +34,6 @@ 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 @@ -89,10 +88,8 @@ import coil.ImageLoader import coil.compose.AsyncImage import coil.decode.GifDecoder import coil.decode.ImageDecoderDecoder -import coil.request.ImageRequest import org.openedx.core.R 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.tagId @@ -823,80 +820,6 @@ fun AutoSizeText( ) } -@Composable -fun DiscoveryCourseItem( - apiHostUrl: String, - course: Course, - windowSize: WindowSize, - onClick: (String) -> Unit, -) { - - val imageWidth by remember(key1 = windowSize) { - mutableStateOf( - windowSize.windowSizeValue( - expanded = 170.dp, - compact = 105.dp - ) - ) - } - - val imageUrl = apiHostUrl.dropLast(1) + course.media.courseImage?.uri - Surface( - modifier = Modifier - .testTag("btn_course_card") - .fillMaxWidth() - .height(140.dp) - .clickable { onClick(course.courseId) } - .background(MaterialTheme.appColors.background), - ) { - Row( - modifier = Modifier - .fillMaxSize() - .background(MaterialTheme.appColors.background), - horizontalArrangement = Arrangement.spacedBy(16.dp), - verticalAlignment = Alignment.CenterVertically - ) { - AsyncImage( - model = ImageRequest.Builder(LocalContext.current) - .data(imageUrl) - .error(R.drawable.core_no_image_course) - .placeholder(R.drawable.core_no_image_course) - .build(), - contentDescription = null, - contentScale = ContentScale.Crop, - modifier = Modifier - .width(imageWidth) - .height(105.dp) - .clip(MaterialTheme.appShapes.courseImageShape) - ) - Column( - modifier = Modifier - .fillMaxWidth() - .height(105.dp), - ) { - Text( - modifier = Modifier - .testTag("txt_course_org") - .padding(top = 12.dp), - text = course.org, color = MaterialTheme.appColors.textFieldHint, - style = MaterialTheme.appTypography.labelMedium - ) - Text( - modifier = Modifier - .testTag("txt_course_title") - .fillMaxWidth() - .padding(top = 8.dp), - text = course.name, - color = MaterialTheme.appColors.textPrimary, - style = MaterialTheme.appTypography.titleSmall, - maxLines = 3, - overflow = TextOverflow.Ellipsis - ) - } - } - } -} - @Composable fun IconText( text: String, diff --git a/core/src/main/res/values-uk/strings.xml b/core/src/main/res/values-uk/strings.xml index b012036fb..dc6e2ffa2 100644 --- a/core/src/main/res/values-uk/strings.xml +++ b/core/src/main/res/values-uk/strings.xml @@ -64,6 +64,7 @@ %1$s зображення профілю + Заглавне зображення для курсу %1$s Якість транслювання відео diff --git a/core/src/main/res/values/strings.xml b/core/src/main/res/values/strings.xml index c2c9927c3..40c288675 100644 --- a/core/src/main/res/values/strings.xml +++ b/core/src/main/res/values/strings.xml @@ -117,6 +117,7 @@ %1$s profile image + Header image for %1$s Download to device Downloading videos… diff --git a/course/src/main/java/org/openedx/course/data/repository/CourseRepository.kt b/course/src/main/java/org/openedx/course/data/repository/CourseRepository.kt index 5ddec50c0..17dc6a240 100644 --- a/course/src/main/java/org/openedx/course/data/repository/CourseRepository.kt +++ b/course/src/main/java/org/openedx/course/data/repository/CourseRepository.kt @@ -1,12 +1,9 @@ package org.openedx.course.data.repository import kotlinx.coroutines.flow.map -import okhttp3.ResponseBody import org.openedx.core.ApiConstants import org.openedx.core.data.api.CourseApi import org.openedx.core.data.model.BlocksCompletionBody -import org.openedx.core.data.model.EnrollBody -import org.openedx.core.data.model.room.CourseEntity import org.openedx.core.data.storage.CorePreferences import org.openedx.core.domain.model.* import org.openedx.core.exception.NoCachedDataException @@ -21,16 +18,6 @@ class CourseRepository( ) { private var courseStructure: CourseStructure? = null - suspend fun getCourseDetail(id: String): Course { - val course = api.getCourseDetail(id, preferencesManager.user?.username) - courseDao.updateCourseEntity(CourseEntity.createFrom(course)) - return course.mapToDomain() - } - - suspend fun getCourseDetailFromCache(id: String): Course? { - return courseDao.getCourseById(id)?.mapToDomain() - } - suspend fun removeDownloadModel(id: String) { downloadDao.removeDownloadModel(id) } @@ -39,16 +26,6 @@ class CourseRepository( list.map { it.mapToDomain() } } - suspend fun enrollInACourse(courseId: String): ResponseBody { - val enrollBody = EnrollBody( - EnrollBody.CourseDetails( - courseId = courseId, - emailOptIn = preferencesManager.user?.email - ) - ) - return api.enrollInACourse(enrollBody) - } - suspend fun preloadCourseStructure(courseId: String) { val response = api.getCourseStructure( "stale-if-error=0", @@ -80,11 +57,6 @@ class CourseRepository( } } - suspend fun getEnrolledCourseFromCacheById(courseId: String): EnrolledCourse? { - val course = courseDao.getEnrolledCourseById(courseId) - return course?.mapToDomain() - } - suspend fun getCourseStatus(courseId: String): CourseComponentStatus { val username = preferencesManager.user?.username ?: "" return api.getCourseStatus(username, courseId).mapToDomain() diff --git a/course/src/main/java/org/openedx/course/data/storage/CourseDao.kt b/course/src/main/java/org/openedx/course/data/storage/CourseDao.kt index ca344c8ef..ca7286e48 100644 --- a/course/src/main/java/org/openedx/course/data/storage/CourseDao.kt +++ b/course/src/main/java/org/openedx/course/data/storage/CourseDao.kt @@ -4,26 +4,18 @@ import androidx.room.Dao import androidx.room.Insert import androidx.room.OnConflictStrategy import androidx.room.Query -import androidx.room.Update -import org.openedx.core.data.model.room.CourseEntity import org.openedx.core.data.model.room.CourseStructureEntity import org.openedx.core.data.model.room.discovery.EnrolledCourseEntity @Dao interface CourseDao { - @Query("SELECT * FROM course_discovery_table WHERE id=:id") - suspend fun getCourseById(id: String): CourseEntity? - @Query("SELECT * FROM course_enrolled_table WHERE id=:id") suspend fun getEnrolledCourseById(id: String): EnrolledCourseEntity? @Query("SELECT * FROM course_structure_table WHERE id=:id") suspend fun getCourseStructureById(id: String): CourseStructureEntity? - @Update - suspend fun updateCourseEntity(courseEntity: CourseEntity) - @Insert(onConflict = OnConflictStrategy.REPLACE) suspend fun insertCourseStructureEntity(vararg courseStructureEntity: CourseStructureEntity) } diff --git a/course/src/main/java/org/openedx/course/domain/interactor/CourseInteractor.kt b/course/src/main/java/org/openedx/course/domain/interactor/CourseInteractor.kt index 52a8a55ec..6c8bd1009 100644 --- a/course/src/main/java/org/openedx/course/domain/interactor/CourseInteractor.kt +++ b/course/src/main/java/org/openedx/course/domain/interactor/CourseInteractor.kt @@ -3,20 +3,11 @@ package org.openedx.course.domain.interactor import org.openedx.core.BlockType import org.openedx.core.domain.model.Block import org.openedx.core.domain.model.CourseStructure -import org.openedx.core.interfaces.EnrollInCourseInteractor import org.openedx.course.data.repository.CourseRepository class CourseInteractor( private val repository: CourseRepository -) : EnrollInCourseInteractor { - - suspend fun getCourseDetails(id: String) = repository.getCourseDetail(id) - - suspend fun getCourseDetailsFromCache(id: String) = repository.getCourseDetailFromCache(id) - - override suspend fun enrollInACourse(id: String) { - repository.enrollInACourse(courseId = id) - } +) { suspend fun preloadCourseStructure(courseId: String) = repository.preloadCourseStructure(courseId) @@ -65,9 +56,6 @@ class CourseInteractor( return courseStructure.copy(blockData = resultBlocks.toList()) } - suspend fun getEnrolledCourseFromCacheById(courseId: String) = - repository.getEnrolledCourseFromCacheById(courseId) - suspend fun getCourseStatus(courseId: String) = repository.getCourseStatus(courseId) suspend fun getCourseDates(courseId: String) = repository.getCourseDates(courseId) diff --git a/course/src/main/java/org/openedx/course/presentation/CourseAnalytics.kt b/course/src/main/java/org/openedx/course/presentation/CourseAnalytics.kt index 53291c460..14de268f3 100644 --- a/course/src/main/java/org/openedx/course/presentation/CourseAnalytics.kt +++ b/course/src/main/java/org/openedx/course/presentation/CourseAnalytics.kt @@ -41,22 +41,6 @@ interface CourseAnalytics { } enum class CourseAnalyticsEvent(val eventName: String, val biValue: String) { - COURSE_ENROLL_CLICKED( - "Discovery:Course Enroll Clicked", - "edx.bi.app.course.enroll.clicked" - ), - COURSE_ENROLL_SUCCESS( - "Discovery:Course Enroll Success", - "edx.bi.app.course.enroll.success" - ), - COURSE_INFO( - "Discovery:Course Info", - "edx.bi.app.discovery.course_info" - ), - PROGRAM_INFO( - "Discovery:Program Info", - "edx.bi.app.discovery.program_info" - ), DASHBOARD( "Course:Dashboard", "edx.bi.app.course.dashboard" @@ -167,7 +151,6 @@ enum class CourseAnalyticsKey(val key: String) { NAME("name"), COURSE_ID("course_id"), COURSE_NAME("course_name"), - CONVERSION("conversion"), OPEN_IN_BROWSER("open_in_browser_url"), COMPONENT("component"), VIDEO_PLAYER("video_player"), diff --git a/course/src/main/java/org/openedx/course/presentation/CourseRouter.kt b/course/src/main/java/org/openedx/course/presentation/CourseRouter.kt index a9bb8dfcf..b2f520679 100644 --- a/course/src/main/java/org/openedx/course/presentation/CourseRouter.kt +++ b/course/src/main/java/org/openedx/course/presentation/CourseRouter.kt @@ -7,10 +7,6 @@ import org.openedx.course.presentation.handouts.HandoutsType interface CourseRouter { - fun navigateToCourseOutline( - fm: FragmentManager, courseId: String, courseTitle: String, enrollmentMode: String - ) - fun navigateToNoAccess( fm: FragmentManager, title: String @@ -63,18 +59,6 @@ interface CourseRouter { fm: FragmentManager, courseId: String, title: String, type: HandoutsType ) - fun navigateToCourseInfo( - fm: FragmentManager, - courseId: String, - infoType: String, - ) - - fun navigateToSignUp(fm: FragmentManager, courseId: String?, infoType: String?) - - fun navigateToSignIn(fm: FragmentManager, courseId: String?, infoType: String?) - - fun navigateToLogistration(fm: FragmentManager, courseId: String?) - fun navigateToDownloadQueue(fm: FragmentManager, descendants: List = arrayListOf()) fun navigateToVideoQuality(fm: FragmentManager, videoQualityType: VideoQualityType) diff --git a/course/src/main/java/org/openedx/course/presentation/ui/CourseUI.kt b/course/src/main/java/org/openedx/course/presentation/ui/CourseUI.kt index d2fadda7c..9523d99e5 100644 --- a/course/src/main/java/org/openedx/course/presentation/ui/CourseUI.kt +++ b/course/src/main/java/org/openedx/course/presentation/ui/CourseUI.kt @@ -9,7 +9,6 @@ import androidx.compose.foundation.Image import androidx.compose.foundation.background import androidx.compose.foundation.border import androidx.compose.foundation.clickable -import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -60,9 +59,7 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.draw.alpha import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.rotate -import androidx.compose.ui.draw.shadow import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.painter.Painter import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalConfiguration import androidx.compose.ui.platform.LocalContext @@ -143,7 +140,7 @@ fun CourseImageHeader( .placeholder(coreR.drawable.core_no_image_course) .build(), contentDescription = stringResource( - id = R.string.course_accessibility_header_image_for, + id = coreR.string.core_accessibility_header_image_for, courseName ), contentScale = contentScale, @@ -1210,70 +1207,6 @@ fun DatesShiftedSnackBar( } } -@Composable -fun WarningLabel( - painter: Painter, - text: String -) { - val borderColor = if (!isSystemInDarkTheme()) { - MaterialTheme.appColors.cardViewBorder - } else { - MaterialTheme.appColors.surface - } - Box( - Modifier - .fillMaxWidth() - .shadow( - 0.dp, - MaterialTheme.appShapes.material.medium - ) - .background( - MaterialTheme.appColors.surface, - MaterialTheme.appShapes.material.medium - ) - .border( - 1.dp, - borderColor, - MaterialTheme.appShapes.material.medium - ) - ) { - Row( - modifier = Modifier - .fillMaxWidth() - .padding( - horizontal = 16.dp, - vertical = 12.dp - ), - verticalAlignment = Alignment.CenterVertically - ) { - Icon( - painter = painter, - contentDescription = null, - tint = MaterialTheme.appColors.warning - ) - Spacer(Modifier.width(12.dp)) - Text( - modifier = Modifier.testTag("txt_enroll_internet_error"), - text = text, - color = MaterialTheme.appColors.textPrimaryVariant, - style = MaterialTheme.appTypography.titleSmall - ) - } - } -} - -@Preview(uiMode = Configuration.UI_MODE_NIGHT_NO) -@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES) -@Composable -private fun WarningLabelPreview() { - OpenEdXTheme { - WarningLabel( - painter = painterResource(id = org.openedx.core.R.drawable.core_ic_offline), - text = stringResource(id = R.string.course_no_internet_label) - ) - } -} - @Preview(uiMode = Configuration.UI_MODE_NIGHT_NO) @Preview(uiMode = Configuration.UI_MODE_NIGHT_YES) @Composable diff --git a/course/src/main/res/values-uk/strings.xml b/course/src/main/res/values-uk/strings.xml index 7ac53f4da..e7799bdbf 100644 --- a/course/src/main/res/values-uk/strings.xml +++ b/course/src/main/res/values-uk/strings.xml @@ -1,8 +1,5 @@ - Записатися зараз - Переглянути курс - Деталі курсу Огляд курсу Зміст курсу Одиниці курсу @@ -31,7 +28,6 @@ Секція \"%s\" завершена. Наступний розділ Повернутись до модуля - Ви не можете записатися на цей курс, оскільки термін запису вже минув. Цей курс ще не розпочався. Ви не підключені до Інтернету. Будь ласка, перевірте ваше підключення до Інтернету. Курс @@ -48,8 +44,6 @@ Щоб перейти до \"%s\", натисніть \"Наступний розділ\". - Заглавне зображення для курсу %1$s - Відтворити відео Відеоплеєр Видалити секцію курсу Завантажити секцію курсу diff --git a/course/src/main/res/values/strings.xml b/course/src/main/res/values/strings.xml index 4a4ef80ed..002fc06a1 100644 --- a/course/src/main/res/values/strings.xml +++ b/course/src/main/res/values/strings.xml @@ -1,8 +1,5 @@ - Enroll now - View course - Course details Course Outline Course content Course units @@ -31,8 +28,6 @@ You\'ve completed \"%s\". Back to outline Next section - You cannot enroll in this course because the enrollment date is over. - To enroll in this course, please make sure you are connected to the internet. This course hasn’t started yet. You are not connected to the Internet. Please check your Internet connection. Course @@ -49,8 +44,6 @@ To proceed with \"%s\" press \"Next section\". Some content in this part of the course is locked for upgraded users only. Dates - You are already enrolled in this course. - Discover You cannot change the download video quality when all videos are downloading Dates Shifted @@ -93,8 +86,6 @@ - Header image for %1$s - Play video Video player Remove course section Download course section 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 9e83411f5..646f606a1 100644 --- a/dashboard/src/androidTest/java/org/openedx/dashboard/presentation/MyCoursesScreenTest.kt +++ b/dashboard/src/androidTest/java/org/openedx/dashboard/presentation/MyCoursesScreenTest.kt @@ -19,8 +19,6 @@ 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 { @@ -162,4 +160,4 @@ class MyCoursesScreenTest { } } -} \ No newline at end of file +} diff --git a/dashboard/src/main/java/org/openedx/dashboard/notifier/DashboardEvent.kt b/dashboard/src/main/java/org/openedx/dashboard/notifier/DashboardEvent.kt deleted file mode 100644 index db6532218..000000000 --- a/dashboard/src/main/java/org/openedx/dashboard/notifier/DashboardEvent.kt +++ /dev/null @@ -1,6 +0,0 @@ -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 deleted file mode 100644 index 5e3fa6e22..000000000 --- a/dashboard/src/main/java/org/openedx/dashboard/notifier/DashboardNotifier.kt +++ /dev/null @@ -1,19 +0,0 @@ -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/dashboard/DashboardAnalytics.kt b/dashboard/src/main/java/org/openedx/dashboard/presentation/DashboardAnalytics.kt similarity index 67% rename from dashboard/src/main/java/org/openedx/dashboard/presentation/dashboard/DashboardAnalytics.kt rename to dashboard/src/main/java/org/openedx/dashboard/presentation/DashboardAnalytics.kt index a0ce2285e..6a69e7a65 100644 --- a/dashboard/src/main/java/org/openedx/dashboard/presentation/dashboard/DashboardAnalytics.kt +++ b/dashboard/src/main/java/org/openedx/dashboard/presentation/DashboardAnalytics.kt @@ -1,4 +1,4 @@ -package org.openedx.dashboard.presentation.dashboard +package org.openedx.dashboard.presentation interface DashboardAnalytics { fun dashboardCourseClickedEvent(courseId: String, courseName: String) diff --git a/dashboard/src/main/java/org/openedx/dashboard/presentation/dashboard/DashboardFragment.kt b/dashboard/src/main/java/org/openedx/dashboard/presentation/DashboardFragment.kt similarity index 99% rename from dashboard/src/main/java/org/openedx/dashboard/presentation/dashboard/DashboardFragment.kt rename to dashboard/src/main/java/org/openedx/dashboard/presentation/DashboardFragment.kt index 0e36bbe2e..1c314c445 100644 --- a/dashboard/src/main/java/org/openedx/dashboard/presentation/dashboard/DashboardFragment.kt +++ b/dashboard/src/main/java/org/openedx/dashboard/presentation/DashboardFragment.kt @@ -1,4 +1,4 @@ -package org.openedx.dashboard.presentation.dashboard +package org.openedx.dashboard.presentation import android.content.res.Configuration.UI_MODE_NIGHT_NO import android.content.res.Configuration.UI_MODE_NIGHT_YES @@ -95,7 +95,6 @@ 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.openedx.dashboard.presentation.DashboardRouter import java.util.Date class DashboardFragment : Fragment() { 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 cca5edecd..6cd185fa9 100644 --- a/dashboard/src/main/java/org/openedx/dashboard/presentation/DashboardRouter.kt +++ b/dashboard/src/main/java/org/openedx/dashboard/presentation/DashboardRouter.kt @@ -10,12 +10,4 @@ interface DashboardRouter { courseTitle: String, enrollmentMode: String, ) - - 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/dashboard/DashboardUIState.kt b/dashboard/src/main/java/org/openedx/dashboard/presentation/DashboardUIState.kt similarity index 81% rename from dashboard/src/main/java/org/openedx/dashboard/presentation/dashboard/DashboardUIState.kt rename to dashboard/src/main/java/org/openedx/dashboard/presentation/DashboardUIState.kt index b0c5b1daa..9f35594db 100644 --- a/dashboard/src/main/java/org/openedx/dashboard/presentation/dashboard/DashboardUIState.kt +++ b/dashboard/src/main/java/org/openedx/dashboard/presentation/DashboardUIState.kt @@ -1,4 +1,4 @@ -package org.openedx.dashboard.presentation.dashboard +package org.openedx.dashboard.presentation import org.openedx.core.domain.model.EnrolledCourse @@ -6,4 +6,4 @@ sealed class DashboardUIState { data class Courses(val courses: List) : DashboardUIState() object Empty : DashboardUIState() object Loading : DashboardUIState() -} \ No newline at end of file +} diff --git a/dashboard/src/main/java/org/openedx/dashboard/presentation/dashboard/DashboardViewModel.kt b/dashboard/src/main/java/org/openedx/dashboard/presentation/DashboardViewModel.kt similarity index 89% rename from dashboard/src/main/java/org/openedx/dashboard/presentation/dashboard/DashboardViewModel.kt rename to dashboard/src/main/java/org/openedx/dashboard/presentation/DashboardViewModel.kt index 9316d831f..0ec06a2c3 100644 --- a/dashboard/src/main/java/org/openedx/dashboard/presentation/dashboard/DashboardViewModel.kt +++ b/dashboard/src/main/java/org/openedx/dashboard/presentation/DashboardViewModel.kt @@ -1,12 +1,9 @@ -package org.openedx.dashboard.presentation.dashboard +package org.openedx.dashboard.presentation 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 @@ -20,10 +17,8 @@ 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.core.system.notifier.DiscoveryNotifier import org.openedx.dashboard.domain.interactor.DashboardInteractor -import org.openedx.dashboard.notifier.DashboardEvent -import org.openedx.dashboard.notifier.DashboardNotifier class DashboardViewModel( @@ -31,8 +26,7 @@ class DashboardViewModel( private val networkConnection: NetworkConnection, private val interactor: DashboardInteractor, private val resourceManager: ResourceManager, - private val courseNotifier: CourseNotifier, - private val dashboardNotifier: DashboardNotifier, + private val discoveryNotifier: DiscoveryNotifier, private val analytics: DashboardAnalytics, private val appUpgradeNotifier: AppUpgradeNotifier ) : BaseViewModel() { @@ -69,17 +63,12 @@ class DashboardViewModel( override fun onCreate(owner: LifecycleOwner) { super.onCreate(owner) viewModelScope.launch { - courseNotifier.notifier.collect { + discoveryNotifier.notifier.collect { if (it is CourseDashboardUpdate) { updateCourses() } } } - dashboardNotifier.notifier.onEach { - if (it is DashboardEvent.UpdateEnrolledCourses) { - updateCourses() - } - }.distinctUntilChanged().launchIn(viewModelScope) } init { @@ -189,4 +178,4 @@ class DashboardViewModel( analytics.dashboardCourseClickedEvent(courseId, courseName) } -} \ No newline at end of file +} diff --git a/dashboard/src/main/res/values/strings.xml b/dashboard/src/main/res/values/strings.xml index 4ba7b2f24..583851adc 100644 --- a/dashboard/src/main/res/values/strings.xml +++ b/dashboard/src/main/res/values/strings.xml @@ -2,8 +2,6 @@ Dashboard Courses - Programs Welcome back. Let\'s keep learning. You are not enrolled in any courses yet. - 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 3ade5f4c6..6fdfdec22 100644 --- a/dashboard/src/test/java/org/openedx/dashboard/presentation/DashboardViewModelTest.kt +++ b/dashboard/src/test/java/org/openedx/dashboard/presentation/DashboardViewModelTest.kt @@ -8,13 +8,16 @@ 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 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 @@ -30,12 +33,8 @@ 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.core.system.notifier.DiscoveryNotifier 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) @@ -50,8 +49,7 @@ class DashboardViewModelTest { private val resourceManager = mockk() private val interactor = mockk() private val networkConnection = mockk() - private val courseNotifier = mockk() - private val dashboardNotifier = spyk() + private val discoveryNotifier = mockk() private val analytics = mockk() private val appUpgradeNotifier = mockk() @@ -79,7 +77,15 @@ class DashboardViewModelTest { @Test fun `getCourses no internet connection`() = runTest { - val viewModel = DashboardViewModel(config, networkConnection, interactor, resourceManager, courseNotifier, dashboardNotifier, analytics, appUpgradeNotifier) + val viewModel = DashboardViewModel( + config, + networkConnection, + interactor, + resourceManager, + discoveryNotifier, + analytics, + appUpgradeNotifier + ) every { networkConnection.isOnline() } returns true coEvery { interactor.getEnrolledCourses(any()) } throws UnknownHostException() advanceUntilIdle() @@ -95,7 +101,15 @@ class DashboardViewModelTest { @Test fun `getCourses unknown error`() = runTest { - val viewModel = DashboardViewModel(config, networkConnection, interactor, resourceManager, courseNotifier, dashboardNotifier, analytics, appUpgradeNotifier) + val viewModel = DashboardViewModel( + config, + networkConnection, + interactor, + resourceManager, + discoveryNotifier, + analytics, + appUpgradeNotifier + ) every { networkConnection.isOnline() } returns true coEvery { interactor.getEnrolledCourses(any()) } throws Exception() advanceUntilIdle() @@ -111,7 +125,15 @@ class DashboardViewModelTest { @Test fun `getCourses from network`() = runTest { - val viewModel = DashboardViewModel(config, networkConnection, interactor, resourceManager, courseNotifier, dashboardNotifier, analytics, appUpgradeNotifier) + val viewModel = DashboardViewModel( + config, + networkConnection, + interactor, + resourceManager, + discoveryNotifier, + analytics, + appUpgradeNotifier + ) every { networkConnection.isOnline() } returns true coEvery { interactor.getEnrolledCourses(any()) } returns dashboardCourseList coEvery { interactor.getEnrolledCoursesFromCache() } returns listOf(mockk()) @@ -127,7 +149,15 @@ class DashboardViewModelTest { @Test fun `getCourses from network with next page`() = runTest { - val viewModel = DashboardViewModel(config, networkConnection, interactor, resourceManager, courseNotifier, dashboardNotifier, analytics, appUpgradeNotifier) + val viewModel = DashboardViewModel( + config, + networkConnection, + interactor, + resourceManager, + discoveryNotifier, + analytics, + appUpgradeNotifier + ) every { networkConnection.isOnline() } returns true coEvery { interactor.getEnrolledCourses(any()) } returns dashboardCourseList.copy( Pagination( @@ -153,7 +183,15 @@ class DashboardViewModelTest { fun `getCourses from cache`() = runTest { every { networkConnection.isOnline() } returns false coEvery { interactor.getEnrolledCoursesFromCache() } returns listOf(mockk()) - val viewModel = DashboardViewModel(config, networkConnection, interactor, resourceManager, courseNotifier, dashboardNotifier, analytics, appUpgradeNotifier) + val viewModel = DashboardViewModel( + config, + networkConnection, + interactor, + resourceManager, + discoveryNotifier, + analytics, + appUpgradeNotifier + ) advanceUntilIdle() @@ -169,7 +207,15 @@ class DashboardViewModelTest { fun `updateCourses no internet error`() = runTest { every { networkConnection.isOnline() } returns true coEvery { interactor.getEnrolledCourses(any()) } returns dashboardCourseList - val viewModel = DashboardViewModel(config, networkConnection, interactor, resourceManager, courseNotifier, dashboardNotifier, analytics, appUpgradeNotifier) + val viewModel = DashboardViewModel( + config, + networkConnection, + interactor, + resourceManager, + discoveryNotifier, + analytics, + appUpgradeNotifier + ) coEvery { interactor.getEnrolledCourses(any()) } throws UnknownHostException() viewModel.updateCourses() @@ -189,7 +235,15 @@ class DashboardViewModelTest { fun `updateCourses unknown exception`() = runTest { every { networkConnection.isOnline() } returns true coEvery { interactor.getEnrolledCourses(any()) } returns dashboardCourseList - val viewModel = DashboardViewModel(config, networkConnection, interactor, resourceManager, courseNotifier, dashboardNotifier, analytics, appUpgradeNotifier) + val viewModel = DashboardViewModel( + config, + networkConnection, + interactor, + resourceManager, + discoveryNotifier, + analytics, + appUpgradeNotifier + ) coEvery { interactor.getEnrolledCourses(any()) } throws Exception() viewModel.updateCourses() @@ -209,7 +263,15 @@ class DashboardViewModelTest { fun `updateCourses success`() = runTest { every { networkConnection.isOnline() } returns true coEvery { interactor.getEnrolledCourses(any()) } returns dashboardCourseList - val viewModel = DashboardViewModel(config, networkConnection, interactor, resourceManager, courseNotifier, dashboardNotifier, analytics, appUpgradeNotifier) + val viewModel = DashboardViewModel( + config, + networkConnection, + interactor, + resourceManager, + discoveryNotifier, + analytics, + appUpgradeNotifier + ) viewModel.updateCourses() advanceUntilIdle() @@ -226,8 +288,23 @@ class DashboardViewModelTest { @Test 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(config, networkConnection, interactor, resourceManager, courseNotifier, dashboardNotifier, analytics, appUpgradeNotifier) + coEvery { interactor.getEnrolledCourses(any()) } returns dashboardCourseList.copy( + Pagination( + 10, + "2", + 2, + "" + ) + ) + val viewModel = DashboardViewModel( + config, + networkConnection, + interactor, + resourceManager, + discoveryNotifier, + analytics, + appUpgradeNotifier + ) viewModel.updateCourses() advanceUntilIdle() @@ -243,8 +320,16 @@ class DashboardViewModelTest { @Test fun `CourseDashboardUpdate notifier test`() = runTest { - coEvery { courseNotifier.notifier } returns flow { emit(CourseDashboardUpdate()) } - val viewModel = DashboardViewModel(config, networkConnection, interactor, resourceManager, courseNotifier, dashboardNotifier, analytics, appUpgradeNotifier) + coEvery { discoveryNotifier.notifier } returns flow { emit(CourseDashboardUpdate()) } + val viewModel = DashboardViewModel( + config, + networkConnection, + interactor, + resourceManager, + discoveryNotifier, + analytics, + appUpgradeNotifier + ) val mockLifeCycleOwner: LifecycleOwner = mockk() val lifecycleRegistry = LifecycleRegistry(mockLifeCycleOwner) diff --git a/discovery/src/main/java/org/openedx/discovery/data/api/DiscoveryApi.kt b/discovery/src/main/java/org/openedx/discovery/data/api/DiscoveryApi.kt new file mode 100644 index 000000000..c92772adb --- /dev/null +++ b/discovery/src/main/java/org/openedx/discovery/data/api/DiscoveryApi.kt @@ -0,0 +1,38 @@ +package org.openedx.discovery.data.api + +import okhttp3.ResponseBody +import org.openedx.core.data.model.EnrollBody +import org.openedx.discovery.data.model.CourseDetails +import org.openedx.discovery.data.model.CourseList +import retrofit2.http.Body +import retrofit2.http.GET +import retrofit2.http.POST +import retrofit2.http.Path +import retrofit2.http.Query + +interface DiscoveryApi { + + @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( + "enroll", + "see_in_catalog", + "see_about_page" + ) + ): CourseList + + @GET("/api/courses/v1/courses/{course_id}") + suspend fun getCourseDetail( + @Path("course_id") courseId: String?, + @Query("username") username: String? = null + ): CourseDetails + + @POST("/api/enrollment/v1/enrollment") + suspend fun enrollInACourse(@Body enrollBody: EnrollBody): ResponseBody +} diff --git a/core/src/main/java/org/openedx/core/data/model/CourseDetails.kt b/discovery/src/main/java/org/openedx/discovery/data/model/CourseDetails.kt similarity index 94% rename from core/src/main/java/org/openedx/core/data/model/CourseDetails.kt rename to discovery/src/main/java/org/openedx/discovery/data/model/CourseDetails.kt index c525d8e43..5cafc1516 100644 --- a/core/src/main/java/org/openedx/core/data/model/CourseDetails.kt +++ b/discovery/src/main/java/org/openedx/discovery/data/model/CourseDetails.kt @@ -1,9 +1,9 @@ -package org.openedx.core.data.model +package org.openedx.discovery.data.model import com.google.gson.annotations.SerializedName -import org.openedx.core.domain.model.Course +import org.openedx.core.data.model.Media import org.openedx.core.utils.TimeUtils -import java.util.* +import org.openedx.discovery.domain.model.Course data class CourseDetails( @SerializedName("blocks_url") diff --git a/core/src/main/java/org/openedx/core/data/model/CourseList.kt b/discovery/src/main/java/org/openedx/discovery/data/model/CourseList.kt similarity index 70% rename from core/src/main/java/org/openedx/core/data/model/CourseList.kt rename to discovery/src/main/java/org/openedx/discovery/data/model/CourseList.kt index 1150c847f..aa100b960 100644 --- a/core/src/main/java/org/openedx/core/data/model/CourseList.kt +++ b/discovery/src/main/java/org/openedx/discovery/data/model/CourseList.kt @@ -1,10 +1,11 @@ -package org.openedx.core.data.model +package org.openedx.discovery.data.model import com.google.gson.annotations.SerializedName +import org.openedx.core.data.model.Pagination data class CourseList( @SerializedName("pagination") val pagination: Pagination, @SerializedName("results") val results: List?, -) \ No newline at end of file +) diff --git a/core/src/main/java/org/openedx/core/data/model/room/CourseEntity.kt b/discovery/src/main/java/org/openedx/discovery/data/model/room/CourseEntity.kt similarity index 94% rename from core/src/main/java/org/openedx/core/data/model/room/CourseEntity.kt rename to discovery/src/main/java/org/openedx/discovery/data/model/room/CourseEntity.kt index 8ddca2bb4..ecf76c24d 100644 --- a/core/src/main/java/org/openedx/core/data/model/room/CourseEntity.kt +++ b/discovery/src/main/java/org/openedx/discovery/data/model/room/CourseEntity.kt @@ -1,12 +1,13 @@ -package org.openedx.core.data.model.room +package org.openedx.discovery.data.model.room import androidx.room.ColumnInfo import androidx.room.Embedded import androidx.room.Entity import androidx.room.PrimaryKey -import org.openedx.core.data.model.CourseDetails -import org.openedx.core.domain.model.Course +import org.openedx.core.data.model.room.MediaDb import org.openedx.core.utils.TimeUtils +import org.openedx.discovery.data.model.CourseDetails +import org.openedx.discovery.domain.model.Course @Entity(tableName = "course_discovery_table") data class CourseEntity( @@ -110,5 +111,4 @@ data class CourseEntity( } } } - -} \ No newline at end of file +} 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 db2f02d65..bdadccecc 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 @@ -1,17 +1,41 @@ package org.openedx.discovery.data.repository -import org.openedx.core.data.api.CourseApi -import org.openedx.core.data.model.room.CourseEntity -import org.openedx.core.domain.model.CourseList -import org.openedx.core.domain.model.Course +import okhttp3.ResponseBody +import org.openedx.core.data.model.EnrollBody +import org.openedx.core.data.storage.CorePreferences +import org.openedx.discovery.data.api.DiscoveryApi +import org.openedx.discovery.data.model.room.CourseEntity import org.openedx.discovery.data.storage.DiscoveryDao +import org.openedx.discovery.domain.model.Course +import org.openedx.discovery.domain.model.CourseList class DiscoveryRepository( - private val api: CourseApi, + private val api: DiscoveryApi, private val dao: DiscoveryDao, + private val preferencesManager: CorePreferences, ) { + suspend fun getCourseDetail(id: String): Course { + val course = api.getCourseDetail(id, preferencesManager.user?.username) + dao.updateCourseEntity(CourseEntity.createFrom(course)) + return course.mapToDomain() + } + + suspend fun getCourseDetailFromCache(id: String): Course? { + return dao.getCourseById(id)?.mapToDomain() + } + + suspend fun enrollInACourse(courseId: String): ResponseBody { + val enrollBody = EnrollBody( + EnrollBody.CourseDetails( + courseId = courseId, + emailOptIn = preferencesManager.user?.email + ) + ) + return api.enrollInACourse(enrollBody) + } + suspend fun getCoursesList( username: String?, organization: String?, @@ -54,4 +78,4 @@ class DiscoveryRepository( pageResponse.results?.map { it.mapToDomain() } ?: emptyList() ) } -} \ No newline at end of file +} diff --git a/discovery/src/main/java/org/openedx/discovery/data/storage/DiscoveryDao.kt b/discovery/src/main/java/org/openedx/discovery/data/storage/DiscoveryDao.kt index 48733b6c6..434d425fa 100644 --- a/discovery/src/main/java/org/openedx/discovery/data/storage/DiscoveryDao.kt +++ b/discovery/src/main/java/org/openedx/discovery/data/storage/DiscoveryDao.kt @@ -4,18 +4,25 @@ import androidx.room.Dao import androidx.room.Insert import androidx.room.OnConflictStrategy import androidx.room.Query -import org.openedx.core.data.model.room.CourseEntity +import androidx.room.Update +import org.openedx.discovery.data.model.room.CourseEntity @Dao interface DiscoveryDao { + @Query("SELECT * FROM course_discovery_table WHERE id=:id") + suspend fun getCourseById(id: String): CourseEntity? + @Insert(onConflict = OnConflictStrategy.REPLACE) suspend fun insertCourseEntity(vararg courseEntity: CourseEntity) + @Update + suspend fun updateCourseEntity(courseEntity: CourseEntity) + @Query("DELETE FROM course_discovery_table") suspend fun clearCachedData() @Query("SELECT * FROM course_discovery_table") - suspend fun readAllData() : List + suspend fun readAllData(): List -} \ No newline at end of file +} diff --git a/discovery/src/main/java/org/openedx/discovery/domain/interactor/DiscoveryInteractor.kt b/discovery/src/main/java/org/openedx/discovery/domain/interactor/DiscoveryInteractor.kt index 8517871d7..a1991b655 100644 --- a/discovery/src/main/java/org/openedx/discovery/domain/interactor/DiscoveryInteractor.kt +++ b/discovery/src/main/java/org/openedx/discovery/domain/interactor/DiscoveryInteractor.kt @@ -1,11 +1,19 @@ package org.openedx.discovery.domain.interactor -import org.openedx.core.domain.model.Course -import org.openedx.core.domain.model.CourseList import org.openedx.discovery.data.repository.DiscoveryRepository +import org.openedx.discovery.domain.model.Course +import org.openedx.discovery.domain.model.CourseList class DiscoveryInteractor(private val repository: DiscoveryRepository) { + suspend fun getCourseDetails(id: String) = repository.getCourseDetail(id) + + suspend fun getCourseDetailsFromCache(id: String) = repository.getCourseDetailFromCache(id) + + suspend fun enrollInACourse(id: String) { + repository.enrollInACourse(courseId = id) + } + suspend fun getCoursesList( username: String?, organization: String?, @@ -23,4 +31,4 @@ class DiscoveryInteractor(private val repository: DiscoveryRepository) { return repository.getCachedCoursesList() } -} \ No newline at end of file +} diff --git a/core/src/main/java/org/openedx/core/domain/model/Course.kt b/discovery/src/main/java/org/openedx/discovery/domain/model/Course.kt similarity index 83% rename from core/src/main/java/org/openedx/core/domain/model/Course.kt rename to discovery/src/main/java/org/openedx/discovery/domain/model/Course.kt index 56de3282b..ae615821c 100644 --- a/core/src/main/java/org/openedx/core/domain/model/Course.kt +++ b/discovery/src/main/java/org/openedx/discovery/domain/model/Course.kt @@ -1,5 +1,6 @@ -package org.openedx.core.domain.model +package org.openedx.discovery.domain.model +import org.openedx.core.domain.model.Media import java.util.Date @@ -23,6 +24,6 @@ data class Course( val end: String, val startDisplay: String, val startType: String, - val overview : String, + val overview: String, val isEnrolled: Boolean -) \ No newline at end of file +) diff --git a/discovery/src/main/java/org/openedx/discovery/domain/model/CourseList.kt b/discovery/src/main/java/org/openedx/discovery/domain/model/CourseList.kt new file mode 100644 index 000000000..d62177391 --- /dev/null +++ b/discovery/src/main/java/org/openedx/discovery/domain/model/CourseList.kt @@ -0,0 +1,8 @@ +package org.openedx.discovery.domain.model + +import org.openedx.core.domain.model.Pagination + +data class CourseList( + val pagination: Pagination, + val results: List, +) diff --git a/discovery/src/main/java/org/openedx/discovery/presentation/DiscoveryAnalytics.kt b/discovery/src/main/java/org/openedx/discovery/presentation/DiscoveryAnalytics.kt index 1cf7c1928..4540a0d7f 100644 --- a/discovery/src/main/java/org/openedx/discovery/presentation/DiscoveryAnalytics.kt +++ b/discovery/src/main/java/org/openedx/discovery/presentation/DiscoveryAnalytics.kt @@ -16,6 +16,14 @@ enum class DiscoveryAnalyticsEvent(val eventName: String, val biValue: String) { "Discovery:Program Info", "edx.bi.app.discovery.program_info" ), + COURSE_ENROLL_CLICKED( + "Discovery:Course Enroll Clicked", + "edx.bi.app.course.enroll.clicked" + ), + COURSE_ENROLL_SUCCESS( + "Discovery:Course Enroll Success", + "edx.bi.app.course.enroll.success" + ), } enum class DiscoveryAnalyticsKey(val key: String) { @@ -23,5 +31,12 @@ enum class DiscoveryAnalyticsKey(val key: String) { COURSE_ID("course_id"), COURSE_NAME("course_name"), CATEGORY("category"), + CONVERSION("conversion"), DISCOVERY("discovery"), } + +enum class DiscoveryAnalyticsScreen(val screenName: String) { + DISCOVERY("Discovery"), + PROGRAM("Program"), + COURSE_INFO("Course Info"), +} 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 53a93f4c1..c1b1c423d 100644 --- a/discovery/src/main/java/org/openedx/discovery/presentation/DiscoveryRouter.kt +++ b/discovery/src/main/java/org/openedx/discovery/presentation/DiscoveryRouter.kt @@ -4,6 +4,12 @@ import androidx.fragment.app.FragmentManager interface DiscoveryRouter { + fun navigateToCourseOutline( + fm: FragmentManager, courseId: String, courseTitle: String, enrollmentMode: String + ) + + fun navigateToLogistration(fm: FragmentManager, courseId: String?) + fun navigateToCourseDetail(fm: FragmentManager, courseId: String) fun navigateToCourseSearch(fm: FragmentManager, querySearch: String) @@ -15,4 +21,6 @@ interface DiscoveryRouter { fun navigateToSignUp(fm: FragmentManager, courseId: String? = null, infoType: String? = null) fun navigateToSignIn(fm: FragmentManager, courseId: String?, infoType: String?) + + fun navigateToEnrolledProgramInfo(fm: FragmentManager, pathId: String) } diff --git a/discovery/src/main/java/org/openedx/discovery/presentation/DiscoveryUIState.kt b/discovery/src/main/java/org/openedx/discovery/presentation/DiscoveryUIState.kt index 3db5d7c04..7001c26c6 100644 --- a/discovery/src/main/java/org/openedx/discovery/presentation/DiscoveryUIState.kt +++ b/discovery/src/main/java/org/openedx/discovery/presentation/DiscoveryUIState.kt @@ -1,8 +1,8 @@ package org.openedx.discovery.presentation -import org.openedx.core.domain.model.Course +import org.openedx.discovery.domain.model.Course sealed class DiscoveryUIState { data class Courses(val courses: List) : DiscoveryUIState() - object Loading : DiscoveryUIState() -} \ No newline at end of file + data object Loading : DiscoveryUIState() +} diff --git a/discovery/src/main/java/org/openedx/discovery/presentation/NativeDiscoveryFragment.kt b/discovery/src/main/java/org/openedx/discovery/presentation/NativeDiscoveryFragment.kt index 08eebc83a..0b43ff371 100644 --- a/discovery/src/main/java/org/openedx/discovery/presentation/NativeDiscoveryFragment.kt +++ b/discovery/src/main/java/org/openedx/discovery/presentation/NativeDiscoveryFragment.kt @@ -59,14 +59,12 @@ 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.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 @@ -81,6 +79,8 @@ 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.openedx.discovery.domain.model.Course +import org.openedx.discovery.presentation.ui.DiscoveryCourseItem class NativeDiscoveryFragment : Fragment() { diff --git a/discovery/src/main/java/org/openedx/discovery/presentation/NativeDiscoveryViewModel.kt b/discovery/src/main/java/org/openedx/discovery/presentation/NativeDiscoveryViewModel.kt index 06132aab6..271e05535 100644 --- a/discovery/src/main/java/org/openedx/discovery/presentation/NativeDiscoveryViewModel.kt +++ b/discovery/src/main/java/org/openedx/discovery/presentation/NativeDiscoveryViewModel.kt @@ -12,13 +12,13 @@ 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 org.openedx.discovery.domain.model.Course class NativeDiscoveryViewModel( private val config: Config, diff --git a/discovery/src/main/java/org/openedx/discovery/presentation/WebViewDiscoveryFragment.kt b/discovery/src/main/java/org/openedx/discovery/presentation/WebViewDiscoveryFragment.kt index 559998a19..7c23ec690 100644 --- a/discovery/src/main/java/org/openedx/discovery/presentation/WebViewDiscoveryFragment.kt +++ b/discovery/src/main/java/org/openedx/discovery/presentation/WebViewDiscoveryFragment.kt @@ -50,9 +50,6 @@ import androidx.lifecycle.LifecycleEventObserver import androidx.lifecycle.LifecycleOwner import org.koin.androidx.viewmodel.ext.android.viewModel import org.koin.core.parameter.parametersOf -import org.openedx.core.presentation.CoreAnalyticsScreen -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.AuthButtonsPanel import org.openedx.core.ui.ConnectionErrorView @@ -66,6 +63,8 @@ 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.discovery.presentation.catalog.CatalogWebViewScreen +import org.openedx.discovery.presentation.catalog.WebViewLink import org.openedx.core.R as CoreR class WebViewDiscoveryFragment : Fragment() { @@ -126,7 +125,7 @@ class WebViewDiscoveryFragment : Fragment() { getString(CoreR.string.platform_name) ), url = param, - source = CoreAnalyticsScreen.DISCOVERY.screenName + source = DiscoveryAnalyticsScreen.DISCOVERY.screenName ).show( requireActivity().supportFragmentManager, ActionDialogFragment::class.simpleName diff --git a/core/src/main/java/org/openedx/core/presentation/catalog/CatalogWebView.kt b/discovery/src/main/java/org/openedx/discovery/presentation/catalog/CatalogWebView.kt similarity index 95% rename from core/src/main/java/org/openedx/core/presentation/catalog/CatalogWebView.kt rename to discovery/src/main/java/org/openedx/discovery/presentation/catalog/CatalogWebView.kt index 021185ca3..42531f8a0 100644 --- a/core/src/main/java/org/openedx/core/presentation/catalog/CatalogWebView.kt +++ b/discovery/src/main/java/org/openedx/discovery/presentation/catalog/CatalogWebView.kt @@ -1,4 +1,4 @@ -package org.openedx.core.presentation.catalog +package org.openedx.discovery.presentation.catalog import android.annotation.SuppressLint import android.webkit.WebResourceRequest @@ -6,8 +6,7 @@ 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 +import org.openedx.discovery.presentation.catalog.WebViewLink.Authority as linkAuthority @SuppressLint("SetJavaScriptEnabled", "ComposableNaming") @Composable diff --git a/core/src/main/java/org/openedx/core/system/DefaultWebViewClient.kt b/discovery/src/main/java/org/openedx/discovery/presentation/catalog/DefaultWebViewClient.kt similarity index 96% rename from core/src/main/java/org/openedx/core/system/DefaultWebViewClient.kt rename to discovery/src/main/java/org/openedx/discovery/presentation/catalog/DefaultWebViewClient.kt index 1273685c0..9cf94ecda 100644 --- a/core/src/main/java/org/openedx/core/system/DefaultWebViewClient.kt +++ b/discovery/src/main/java/org/openedx/discovery/presentation/catalog/DefaultWebViewClient.kt @@ -1,4 +1,4 @@ -package org.openedx.core.system +package org.openedx.discovery.presentation.catalog import android.content.Context import android.graphics.Bitmap @@ -8,7 +8,6 @@ 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( diff --git a/core/src/main/java/org/openedx/core/presentation/catalog/WebViewLink.kt b/discovery/src/main/java/org/openedx/discovery/presentation/catalog/WebViewLink.kt similarity index 90% rename from core/src/main/java/org/openedx/core/presentation/catalog/WebViewLink.kt rename to discovery/src/main/java/org/openedx/discovery/presentation/catalog/WebViewLink.kt index f066a3ae8..a467707ce 100644 --- a/core/src/main/java/org/openedx/core/presentation/catalog/WebViewLink.kt +++ b/discovery/src/main/java/org/openedx/discovery/presentation/catalog/WebViewLink.kt @@ -1,4 +1,4 @@ -package org.openedx.core.presentation.catalog +package org.openedx.discovery.presentation.catalog import android.net.Uri import org.openedx.core.extension.getQueryParams @@ -41,7 +41,7 @@ class WebViewLink( } // Validate the Uri authority - val uriAuthority = Authority.values().find { it.key == uri.authority } ?: return null + val uriAuthority = Authority.entries.find { it.key == uri.authority } ?: return null // Parse the Uri params val params = uri.getQueryParams() diff --git a/course/src/main/java/org/openedx/course/presentation/detail/CourseDetailsFragment.kt b/discovery/src/main/java/org/openedx/discovery/presentation/detail/CourseDetailsFragment.kt similarity index 94% rename from course/src/main/java/org/openedx/course/presentation/detail/CourseDetailsFragment.kt rename to discovery/src/main/java/org/openedx/discovery/presentation/detail/CourseDetailsFragment.kt index 56bbb6937..813994307 100644 --- a/course/src/main/java/org/openedx/course/presentation/detail/CourseDetailsFragment.kt +++ b/discovery/src/main/java/org/openedx/discovery/presentation/detail/CourseDetailsFragment.kt @@ -1,4 +1,4 @@ -package org.openedx.course.presentation.detail +package org.openedx.discovery.presentation.detail import android.annotation.SuppressLint import android.content.Intent @@ -79,7 +79,6 @@ 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.UIMessage -import org.openedx.core.domain.model.Course import org.openedx.core.domain.model.Media import org.openedx.core.extension.isEmailValid import org.openedx.core.ui.AuthButtonsPanel @@ -98,20 +97,21 @@ import org.openedx.core.ui.theme.appColors import org.openedx.core.ui.theme.appTypography import org.openedx.core.ui.windowSizeValue import org.openedx.core.utils.EmailUtil -import org.openedx.course.R -import org.openedx.course.presentation.CourseRouter -import org.openedx.course.presentation.ui.CourseImageHeader -import org.openedx.course.presentation.ui.WarningLabel +import org.openedx.discovery.R +import org.openedx.discovery.domain.model.Course +import org.openedx.discovery.presentation.DiscoveryRouter +import org.openedx.discovery.presentation.ui.ImageHeader +import org.openedx.discovery.presentation.ui.WarningLabel import java.nio.charset.StandardCharsets import java.util.Date -import org.openedx.course.R as courseR +import org.openedx.core.R as CoreR class CourseDetailsFragment : Fragment() { private val viewModel by viewModel { parametersOf(requireArguments().getString(ARG_COURSE_ID, "")) } - private val router by inject() + private val router by inject() override fun onCreateView( inflater: LayoutInflater, @@ -167,7 +167,10 @@ class CourseDetailsFragment : Fragment() { } else -> { - viewModel.enrollInACourse(currentState.course.courseId, currentState.course.name) + viewModel.enrollInACourse( + currentState.course.courseId, + currentState.course.name + ) } } } @@ -279,7 +282,7 @@ internal fun CourseDetailsScreen( modifier = Modifier .fillMaxWidth() .zIndex(1f), - label = stringResource(id = courseR.string.course_details), + label = stringResource(id = R.string.discovery_course_details), canShowBackBtn = true, onBackClick = onBackClick ) @@ -354,7 +357,7 @@ internal fun CourseDetailsScreen( color = MaterialTheme.appColors.background ) { CourseDescription( - paddingModifier = webViewPadding, + modifier = webViewPadding, apiHostUrl = apiHostUrl, body = htmlBody, onWebPageLoaded = { @@ -415,20 +418,19 @@ private fun CourseDetailNativeContent( } val buttonText = if (course.isEnrolled) { - stringResource(id = R.string.course_view_course) + stringResource(id = R.string.discovery_view_course) } else { - stringResource(id = R.string.course_enroll_now) + stringResource(id = R.string.discovery_enroll_now) } Column { Box(contentAlignment = Alignment.Center) { - CourseImageHeader( + ImageHeader( modifier = Modifier .aspectRatio(1.86f) .padding(6.dp), apiHostUrl = apiHostUrl, courseImage = course.media.image?.large, - courseCertificate = null, courseName = course.name ) if (!course.media.courseVideo?.uri.isNullOrEmpty()) { @@ -440,8 +442,8 @@ private fun CourseDetailNativeContent( ) { Icon( modifier = Modifier.size(40.dp), - painter = painterResource(courseR.drawable.course_ic_play), - contentDescription = stringResource(id = R.string.course_accessibility_play_video), + painter = painterResource(R.drawable.discovery_ic_play), + contentDescription = stringResource(id = R.string.discovery_accessibility_play_video), tint = Color.LightGray ) } @@ -515,9 +517,9 @@ private fun CourseDetailNativeContentLandscape( } val buttonText = if (course.isEnrolled) { - stringResource(id = R.string.course_view_course) + stringResource(id = R.string.discovery_view_course) } else { - stringResource(id = R.string.course_enroll_now) + stringResource(id = R.string.discovery_enroll_now) } Row( @@ -571,13 +573,12 @@ private fun CourseDetailNativeContentLandscape( } Spacer(Modifier.width(24.dp)) Box(contentAlignment = Alignment.Center) { - CourseImageHeader( + ImageHeader( modifier = Modifier .width(263.dp) .height(200.dp), apiHostUrl = apiHostUrl, courseImage = course.media.image?.large, - courseCertificate = null, courseName = course.name ) if (!course.media.courseVideo?.uri.isNullOrEmpty()) { @@ -589,7 +590,7 @@ private fun CourseDetailNativeContentLandscape( ) { Icon( modifier = Modifier.size(40.dp), - painter = painterResource(courseR.drawable.course_ic_play), + painter = painterResource(R.drawable.discovery_ic_play), contentDescription = null, tint = Color.LightGray ) @@ -603,28 +604,28 @@ private fun CourseDetailNativeContentLandscape( private fun EnrollOverLabel() { WarningLabel( painter = rememberVectorPainter(Icons.Outlined.Report), - text = stringResource(id = courseR.string.course_you_cant_enroll) + text = stringResource(id = R.string.discovery_you_cant_enroll) ) } @Composable private fun NoInternetLabel() { WarningLabel( - painter = painterResource(id = org.openedx.core.R.drawable.core_ic_offline), - text = stringResource(id = courseR.string.course_no_internet_label) + painter = painterResource(id = CoreR.drawable.core_ic_offline), + text = stringResource(id = R.string.discovery_no_internet_label) ) } @Composable @SuppressLint("SetJavaScriptEnabled") private fun CourseDescription( - paddingModifier: Modifier, + modifier: Modifier, apiHostUrl: String, body: String, onWebPageLoaded: () -> Unit ) { val context = LocalContext.current - AndroidView(modifier = Modifier.then(paddingModifier), factory = { + AndroidView(modifier = Modifier.then(modifier), factory = { WebView(context).apply { webViewClient = object : WebViewClient() { override fun onPageCommitVisible(view: WebView?, url: String?) { diff --git a/course/src/main/java/org/openedx/course/presentation/detail/CourseDetailsUIState.kt b/discovery/src/main/java/org/openedx/discovery/presentation/detail/CourseDetailsUIState.kt similarity index 51% rename from course/src/main/java/org/openedx/course/presentation/detail/CourseDetailsUIState.kt rename to discovery/src/main/java/org/openedx/discovery/presentation/detail/CourseDetailsUIState.kt index 480fd2edc..3999beb6d 100644 --- a/course/src/main/java/org/openedx/course/presentation/detail/CourseDetailsUIState.kt +++ b/discovery/src/main/java/org/openedx/discovery/presentation/detail/CourseDetailsUIState.kt @@ -1,10 +1,10 @@ -package org.openedx.course.presentation.detail +package org.openedx.discovery.presentation.detail -import org.openedx.core.domain.model.Course +import org.openedx.discovery.domain.model.Course sealed class CourseDetailsUIState { data class CourseData(val course: Course, val isUserLoggedIn: Boolean = false) : CourseDetailsUIState() - object Loading : CourseDetailsUIState() + data object Loading : CourseDetailsUIState() } diff --git a/course/src/main/java/org/openedx/course/presentation/detail/CourseDetailsViewModel.kt b/discovery/src/main/java/org/openedx/discovery/presentation/detail/CourseDetailsViewModel.kt similarity index 82% rename from course/src/main/java/org/openedx/course/presentation/detail/CourseDetailsViewModel.kt rename to discovery/src/main/java/org/openedx/discovery/presentation/detail/CourseDetailsViewModel.kt index ddc81f2c5..c68dd1c47 100644 --- a/course/src/main/java/org/openedx/course/presentation/detail/CourseDetailsViewModel.kt +++ b/discovery/src/main/java/org/openedx/discovery/presentation/detail/CourseDetailsViewModel.kt @@ -1,4 +1,4 @@ -package org.openedx.course.presentation.detail +package org.openedx.discovery.presentation.detail import androidx.lifecycle.LiveData import androidx.lifecycle.MutableLiveData @@ -10,26 +10,26 @@ 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.CourseDashboardUpdate -import org.openedx.core.system.notifier.CourseNotifier -import org.openedx.course.domain.interactor.CourseInteractor -import org.openedx.course.presentation.CourseAnalytics -import org.openedx.course.presentation.CourseAnalyticsEvent -import org.openedx.course.presentation.CourseAnalyticsKey +import org.openedx.core.system.notifier.DiscoveryNotifier +import org.openedx.discovery.domain.interactor.DiscoveryInteractor +import org.openedx.discovery.domain.model.Course +import org.openedx.discovery.presentation.DiscoveryAnalytics +import org.openedx.discovery.presentation.DiscoveryAnalyticsEvent +import org.openedx.discovery.presentation.DiscoveryAnalyticsKey class CourseDetailsViewModel( val courseId: String, private val config: Config, private val corePreferences: CorePreferences, private val networkConnection: NetworkConnection, - private val interactor: CourseInteractor, + private val interactor: DiscoveryInteractor, private val resourceManager: ResourceManager, - private val notifier: CourseNotifier, - private val analytics: CourseAnalytics, + private val notifier: DiscoveryNotifier, + private val analytics: DiscoveryAnalytics, ) : BaseViewModel() { val apiHostUrl get() = config.getApiHostURL() val isUserLoggedIn get() = corePreferences.user != null @@ -130,24 +130,24 @@ class CourseDetailsViewModel( } private fun courseEnrollClickedEvent(courseId: String, courseTitle: String) { - logEvent(CourseAnalyticsEvent.COURSE_ENROLL_CLICKED, courseId, courseTitle) + logEvent(DiscoveryAnalyticsEvent.COURSE_ENROLL_CLICKED, courseId, courseTitle) } private fun courseEnrollSuccessEvent(courseId: String, courseTitle: String) { - logEvent(CourseAnalyticsEvent.COURSE_ENROLL_SUCCESS, courseId, courseTitle) + logEvent(DiscoveryAnalyticsEvent.COURSE_ENROLL_SUCCESS, courseId, courseTitle) } private fun logEvent( - event: CourseAnalyticsEvent, + event: DiscoveryAnalyticsEvent, courseId: String, courseTitle: String, ) { analytics.logEvent( event.eventName, buildMap { - put(CourseAnalyticsKey.NAME.key, event.biValue) - put(CourseAnalyticsKey.COURSE_ID.key, courseId) - put(CourseAnalyticsKey.COURSE_NAME.key, courseTitle) - put(CourseAnalyticsKey.CONVERSION.key, courseId) + put(DiscoveryAnalyticsKey.NAME.key, event.biValue) + put(DiscoveryAnalyticsKey.COURSE_ID.key, courseId) + put(DiscoveryAnalyticsKey.COURSE_NAME.key, courseTitle) + put(DiscoveryAnalyticsKey.CONVERSION.key, courseId) } ) } diff --git a/course/src/main/java/org/openedx/course/presentation/info/CourseInfoFragment.kt b/discovery/src/main/java/org/openedx/discovery/presentation/info/CourseInfoFragment.kt similarity index 96% rename from course/src/main/java/org/openedx/course/presentation/info/CourseInfoFragment.kt rename to discovery/src/main/java/org/openedx/discovery/presentation/info/CourseInfoFragment.kt index 0f727d4b1..2de805731 100644 --- a/course/src/main/java/org/openedx/course/presentation/info/CourseInfoFragment.kt +++ b/discovery/src/main/java/org/openedx/discovery/presentation/info/CourseInfoFragment.kt @@ -1,4 +1,4 @@ -package org.openedx.course.presentation.info +package org.openedx.discovery.presentation.info import android.annotation.SuppressLint import android.content.res.Configuration @@ -42,9 +42,6 @@ import androidx.fragment.app.Fragment import org.koin.androidx.viewmodel.ext.android.viewModel import org.koin.core.parameter.parametersOf import org.openedx.core.UIMessage -import org.openedx.core.presentation.CoreAnalyticsScreen -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.AuthButtonsPanel @@ -59,10 +56,13 @@ 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.course.R +import org.openedx.discovery.R +import org.openedx.discovery.presentation.DiscoveryAnalyticsScreen +import org.openedx.discovery.presentation.catalog.CatalogWebViewScreen +import org.openedx.discovery.presentation.catalog.WebViewLink import java.util.concurrent.atomic.AtomicReference import org.openedx.core.R as CoreR -import org.openedx.core.presentation.catalog.WebViewLink.Authority as linkAuthority +import org.openedx.discovery.presentation.catalog.WebViewLink.Authority as linkAuthority class CourseInfoFragment : Fragment() { @@ -169,7 +169,7 @@ class CourseInfoFragment : Fragment() { getString(CoreR.string.platform_name) ), url = param, - source = CoreAnalyticsScreen.COURSE_INFO.screenName + source = DiscoveryAnalyticsScreen.COURSE_INFO.screenName ).show( requireActivity().supportFragmentManager, ActionDialogFragment::class.simpleName @@ -277,7 +277,7 @@ private fun CourseInfoScreen( horizontalAlignment = Alignment.CenterHorizontally, ) { Toolbar( - label = stringResource(id = R.string.course_discover), + label = stringResource(id = R.string.discovery_Discovery), canShowBackBtn = true, onBackClick = onBackClick ) diff --git a/course/src/main/java/org/openedx/course/presentation/info/CourseInfoUIState.kt b/discovery/src/main/java/org/openedx/discovery/presentation/info/CourseInfoUIState.kt similarity index 83% rename from course/src/main/java/org/openedx/course/presentation/info/CourseInfoUIState.kt rename to discovery/src/main/java/org/openedx/discovery/presentation/info/CourseInfoUIState.kt index e75a7873e..ffabf1daf 100644 --- a/course/src/main/java/org/openedx/course/presentation/info/CourseInfoUIState.kt +++ b/discovery/src/main/java/org/openedx/discovery/presentation/info/CourseInfoUIState.kt @@ -1,4 +1,4 @@ -package org.openedx.course.presentation.info +package org.openedx.discovery.presentation.info import java.util.concurrent.atomic.AtomicReference diff --git a/course/src/main/java/org/openedx/course/presentation/info/CourseInfoViewModel.kt b/discovery/src/main/java/org/openedx/discovery/presentation/info/CourseInfoViewModel.kt similarity index 74% rename from course/src/main/java/org/openedx/course/presentation/info/CourseInfoViewModel.kt rename to discovery/src/main/java/org/openedx/discovery/presentation/info/CourseInfoViewModel.kt index b5679e0c1..6d41ac4b1 100644 --- a/course/src/main/java/org/openedx/course/presentation/info/CourseInfoViewModel.kt +++ b/discovery/src/main/java/org/openedx/discovery/presentation/info/CourseInfoViewModel.kt @@ -1,7 +1,8 @@ -package org.openedx.course.presentation.info +package org.openedx.discovery.presentation.info import androidx.fragment.app.FragmentManager import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharedFlow @@ -9,23 +10,24 @@ import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext import org.openedx.core.BaseViewModel import org.openedx.core.UIMessage import org.openedx.core.config.Config import org.openedx.core.data.storage.CorePreferences import org.openedx.core.extension.isInternetError import org.openedx.core.presentation.CoreAnalyticsKey -import org.openedx.core.presentation.catalog.WebViewLink 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.R -import org.openedx.course.domain.interactor.CourseInteractor -import org.openedx.course.presentation.CourseAnalytics -import org.openedx.course.presentation.CourseAnalyticsEvent -import org.openedx.course.presentation.CourseAnalyticsKey -import org.openedx.course.presentation.CourseRouter +import org.openedx.core.system.notifier.DiscoveryNotifier +import org.openedx.discovery.R +import org.openedx.discovery.domain.interactor.DiscoveryInteractor +import org.openedx.discovery.presentation.DiscoveryAnalytics +import org.openedx.discovery.presentation.DiscoveryAnalyticsEvent +import org.openedx.discovery.presentation.DiscoveryAnalyticsKey +import org.openedx.discovery.presentation.DiscoveryRouter +import org.openedx.discovery.presentation.catalog.WebViewLink import java.util.concurrent.atomic.AtomicReference import org.openedx.core.R as CoreR @@ -34,11 +36,11 @@ class CourseInfoViewModel( val infoType: String, private val config: Config, private val networkConnection: NetworkConnection, - private val router: CourseRouter, - private val interactor: CourseInteractor, - private val notifier: CourseNotifier, + private val router: DiscoveryRouter, + private val interactor: DiscoveryInteractor, + private val notifier: DiscoveryNotifier, private val resourceManager: ResourceManager, - private val analytics: CourseAnalytics, + private val analytics: DiscoveryAnalytics, corePreferences: CorePreferences, ) : BaseViewModel() { @@ -83,11 +85,13 @@ class CourseInfoViewModel( viewModelScope.launch { _showAlert.emit(false) try { - val isCourseEnrolled = interactor.getEnrolledCourseFromCacheById(courseId) != null + val isCourseEnrolled = withContext(Dispatchers.IO) { + interactor.getCourseDetails(courseId) + }.isEnrolled if (isCourseEnrolled) { _uiMessage.emit( - UIMessage.ToastMessage(resourceManager.getString(R.string.course_you_are_already_enrolled)) + UIMessage.ToastMessage(resourceManager.getString(R.string.discovery_you_are_already_enrolled)) ) _uiState.update { it.copy(enrollmentSuccess = AtomicReference(courseId)) } return@launch @@ -96,6 +100,9 @@ class CourseInfoViewModel( interactor.enrollInACourse(courseId) courseEnrollSuccessEvent(courseId) notifier.send(CourseDashboardUpdate()) + _uiMessage.emit( + UIMessage.ToastMessage(resourceManager.getString(R.string.discovery_enrolled_successfully)) + ) _uiState.update { it.copy(enrollmentSuccess = AtomicReference(courseId)) } } catch (e: Exception) { if (e.isInternetError()) { @@ -139,32 +146,32 @@ class CourseInfoViewModel( } fun courseInfoClickedEvent(courseId: String) { - logEvent(CourseAnalyticsEvent.COURSE_INFO, courseId) + logEvent(DiscoveryAnalyticsEvent.COURSE_INFO, courseId) } fun programInfoClickedEvent(courseId: String) { - logEvent(CourseAnalyticsEvent.PROGRAM_INFO, courseId) + logEvent(DiscoveryAnalyticsEvent.PROGRAM_INFO, courseId) } fun courseEnrollClickedEvent(courseId: String) { - logEvent(CourseAnalyticsEvent.COURSE_ENROLL_CLICKED, courseId) + logEvent(DiscoveryAnalyticsEvent.COURSE_ENROLL_CLICKED, courseId) } private fun courseEnrollSuccessEvent(courseId: String) { - logEvent(CourseAnalyticsEvent.COURSE_ENROLL_SUCCESS, courseId) + logEvent(DiscoveryAnalyticsEvent.COURSE_ENROLL_SUCCESS, courseId) } private fun logEvent( - event: CourseAnalyticsEvent, + event: DiscoveryAnalyticsEvent, courseId: String, ) { analytics.logEvent( event.eventName, buildMap { - put(CourseAnalyticsKey.NAME.key, event.biValue) - put(CourseAnalyticsKey.COURSE_ID.key, courseId) - put(CourseAnalyticsKey.CATEGORY.key, CoreAnalyticsKey.DISCOVERY.key) - put(CourseAnalyticsKey.CONVERSION.key, courseId) + put(DiscoveryAnalyticsKey.NAME.key, event.biValue) + put(DiscoveryAnalyticsKey.COURSE_ID.key, courseId) + put(DiscoveryAnalyticsKey.CATEGORY.key, CoreAnalyticsKey.DISCOVERY.key) + put(DiscoveryAnalyticsKey.CONVERSION.key, courseId) } ) } diff --git a/dashboard/src/main/java/org/openedx/dashboard/presentation/program/ProgramFragment.kt b/discovery/src/main/java/org/openedx/discovery/presentation/program/ProgramFragment.kt similarity index 95% rename from dashboard/src/main/java/org/openedx/dashboard/presentation/program/ProgramFragment.kt rename to discovery/src/main/java/org/openedx/discovery/presentation/program/ProgramFragment.kt index bb06cabc1..fd71e8543 100644 --- a/dashboard/src/main/java/org/openedx/dashboard/presentation/program/ProgramFragment.kt +++ b/discovery/src/main/java/org/openedx/discovery/presentation/program/ProgramFragment.kt @@ -1,4 +1,4 @@ -package org.openedx.dashboard.presentation.program +package org.openedx.discovery.presentation.program import android.content.res.Configuration import android.os.Bundle @@ -43,9 +43,6 @@ 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.CoreAnalyticsScreen -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 @@ -59,9 +56,12 @@ 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.discovery.R +import org.openedx.discovery.presentation.DiscoveryAnalyticsScreen +import org.openedx.discovery.presentation.catalog.CatalogWebViewScreen +import org.openedx.discovery.presentation.catalog.WebViewLink import org.openedx.core.R as coreR -import org.openedx.core.presentation.catalog.WebViewLink.Authority as linkAuthority +import org.openedx.discovery.presentation.catalog.WebViewLink.Authority as linkAuthority class ProgramFragment(private val myPrograms: Boolean = false) : Fragment() { @@ -100,7 +100,7 @@ class ProgramFragment(private val myPrograms: Boolean = false) : Fragment() { fragmentManager = requireActivity().supportFragmentManager, courseId = courseId, ) - context.toastMessage(getString(R.string.dashboard_enrolled_successfully)) + context.toastMessage(getString(R.string.discovery_enrolled_successfully)) } else { InfoDialogFragment.newInstance( title = getString(coreR.string.core_enrollment_error), @@ -171,14 +171,12 @@ class ProgramFragment(private val myPrograms: Boolean = false) : Fragment() { getString(coreR.string.platform_name) ), url = param, - source = CoreAnalyticsScreen.PROGRAM.screenName + source = DiscoveryAnalyticsScreen.PROGRAM.screenName ).show( requireActivity().supportFragmentManager, ActionDialogFragment::class.simpleName ) } - - else -> {} } }, refreshSessionCookie = { @@ -268,7 +266,7 @@ private fun ProgramInfoScreen( horizontalAlignment = Alignment.CenterHorizontally, ) { Toolbar( - label = stringResource(id = R.string.dashboard_programs), + label = stringResource(id = R.string.discovery_programs), canShowBackBtn = canShowBackBtn, onBackClick = onBackClick ) diff --git a/dashboard/src/main/java/org/openedx/dashboard/presentation/program/ProgramUIState.kt b/discovery/src/main/java/org/openedx/discovery/presentation/program/ProgramUIState.kt similarity index 62% rename from dashboard/src/main/java/org/openedx/dashboard/presentation/program/ProgramUIState.kt rename to discovery/src/main/java/org/openedx/discovery/presentation/program/ProgramUIState.kt index 8f9b83c5c..fa7f395d7 100644 --- a/dashboard/src/main/java/org/openedx/dashboard/presentation/program/ProgramUIState.kt +++ b/discovery/src/main/java/org/openedx/discovery/presentation/program/ProgramUIState.kt @@ -1,10 +1,10 @@ -package org.openedx.dashboard.presentation.program +package org.openedx.discovery.presentation.program import org.openedx.core.UIMessage sealed class ProgramUIState { - object Loading : ProgramUIState() - object Loaded : ProgramUIState() + data object Loading : ProgramUIState() + data object Loaded : ProgramUIState() class CourseEnrolled(val courseId: String, val isEnrolled: Boolean) : ProgramUIState() diff --git a/dashboard/src/main/java/org/openedx/dashboard/presentation/program/ProgramViewModel.kt b/discovery/src/main/java/org/openedx/discovery/presentation/program/ProgramViewModel.kt similarity index 80% rename from dashboard/src/main/java/org/openedx/dashboard/presentation/program/ProgramViewModel.kt rename to discovery/src/main/java/org/openedx/discovery/presentation/program/ProgramViewModel.kt index 28de4a3d9..3a680da1b 100644 --- a/dashboard/src/main/java/org/openedx/dashboard/presentation/program/ProgramViewModel.kt +++ b/discovery/src/main/java/org/openedx/discovery/presentation/program/ProgramViewModel.kt @@ -1,4 +1,4 @@ -package org.openedx.dashboard.presentation.program +package org.openedx.discovery.presentation.program import androidx.fragment.app.FragmentManager import androidx.lifecycle.viewModelScope @@ -12,22 +12,23 @@ 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 +import org.openedx.core.system.notifier.CourseDashboardUpdate +import org.openedx.core.system.notifier.DiscoveryNotifier +import org.openedx.core.system.notifier.NavigationToDiscovery +import org.openedx.discovery.domain.interactor.DiscoveryInteractor +import org.openedx.discovery.presentation.DiscoveryRouter class ProgramViewModel( private val config: Config, private val networkConnection: NetworkConnection, - private val router: DashboardRouter, - private val notifier: DashboardNotifier, + private val router: DiscoveryRouter, + private val notifier: DiscoveryNotifier, private val edxCookieManager: AppCookieManager, private val resourceManager: ResourceManager, - private val courseInteractor: EnrollInCourseInteractor + private val interactor: DiscoveryInteractor, ) : BaseViewModel() { val uriScheme: String get() = config.getUriScheme() @@ -52,9 +53,9 @@ class ProgramViewModel( showLoading(true) viewModelScope.launch { try { - courseInteractor.enrollInACourse(courseId) + interactor.enrollInACourse(courseId) _uiState.emit(ProgramUIState.CourseEnrolled(courseId, true)) - notifier.send(DashboardEvent.UpdateEnrolledCourses) + notifier.send(CourseDashboardUpdate()) } catch (e: Exception) { if (e.isInternetError()) { _uiState.emit( @@ -71,7 +72,7 @@ class ProgramViewModel( fun onProgramCardClick(fragmentManager: FragmentManager, pathId: String) { if (pathId.isNotEmpty()) { - router.navigateToProgramInfo(fm = fragmentManager, pathId = pathId) + router.navigateToEnrolledProgramInfo(fm = fragmentManager, pathId = pathId) } } @@ -97,7 +98,7 @@ class ProgramViewModel( } fun navigateToDiscovery() { - viewModelScope.launch { notifier.send(DashboardEvent.NavigationToDiscovery) } + viewModelScope.launch { notifier.send(NavigationToDiscovery()) } } fun refreshCookie() { 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 f72540b14..e13e6f0bb 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 @@ -64,11 +64,9 @@ 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.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 @@ -80,7 +78,9 @@ 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.domain.model.Course import org.openedx.discovery.presentation.DiscoveryRouter +import org.openedx.discovery.presentation.ui.DiscoveryCourseItem import org.openedx.discovery.R as discoveryR class CourseSearchFragment : Fragment() { @@ -492,4 +492,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/search/CourseSearchUIState.kt b/discovery/src/main/java/org/openedx/discovery/presentation/search/CourseSearchUIState.kt index 2b52b750c..8142df813 100644 --- a/discovery/src/main/java/org/openedx/discovery/presentation/search/CourseSearchUIState.kt +++ b/discovery/src/main/java/org/openedx/discovery/presentation/search/CourseSearchUIState.kt @@ -1,8 +1,8 @@ package org.openedx.discovery.presentation.search -import org.openedx.core.domain.model.Course +import org.openedx.discovery.domain.model.Course sealed class CourseSearchUIState { data class Courses(val courses: List, val numCourses: Int) : CourseSearchUIState() - object Loading : CourseSearchUIState() -} \ No newline at end of file + data object Loading : CourseSearchUIState() +} 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 3dbb7c6b9..ea6c5ba35 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 @@ -14,10 +14,10 @@ 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.domain.model.Course import org.openedx.discovery.presentation.DiscoveryAnalytics class CourseSearchViewModel( @@ -142,4 +142,4 @@ class CourseSearchViewModel( } } -} \ No newline at end of file +} diff --git a/discovery/src/main/java/org/openedx/discovery/presentation/ui/DiscoveryUI.kt b/discovery/src/main/java/org/openedx/discovery/presentation/ui/DiscoveryUI.kt new file mode 100644 index 000000000..e1b6645ea --- /dev/null +++ b/discovery/src/main/java/org/openedx/discovery/presentation/ui/DiscoveryUI.kt @@ -0,0 +1,230 @@ +package org.openedx.discovery.presentation.ui + +import android.content.res.Configuration +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.isSystemInDarkTheme +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.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.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.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.draw.clip +import androidx.compose.ui.draw.shadow +import androidx.compose.ui.graphics.painter.Painter +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalConfiguration +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import coil.compose.AsyncImage +import coil.request.ImageRequest +import org.openedx.core.extension.isLinkValid +import org.openedx.core.ui.WindowSize +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.ui.windowSizeValue +import org.openedx.discovery.R +import org.openedx.discovery.domain.model.Course +import org.openedx.core.R as CoreR + + +@Composable +fun ImageHeader( + modifier: Modifier, + apiHostUrl: String, + courseImage: String?, + courseName: String, +) { + val configuration = LocalConfiguration.current + val windowSize = rememberWindowSize() + val contentScale = + if (!windowSize.isTablet && configuration.orientation == Configuration.ORIENTATION_LANDSCAPE) { + ContentScale.Fit + } else { + ContentScale.Crop + } + val imageUrl = if (courseImage?.isLinkValid() == true) { + courseImage + } else { + apiHostUrl.dropLast(1) + courseImage + } + Box(modifier = modifier, contentAlignment = Alignment.Center) { + AsyncImage( + model = ImageRequest.Builder(LocalContext.current) + .data(imageUrl) + .error(CoreR.drawable.core_no_image_course) + .placeholder(CoreR.drawable.core_no_image_course) + .build(), + contentDescription = stringResource( + id = CoreR.string.core_accessibility_header_image_for, + courseName + ), + contentScale = contentScale, + modifier = Modifier + .fillMaxSize() + .clip(MaterialTheme.appShapes.cardShape) + ) + } +} + +@Composable +fun DiscoveryCourseItem( + apiHostUrl: String, + course: Course, + windowSize: WindowSize, + onClick: (String) -> Unit, +) { + + val imageWidth by remember(key1 = windowSize) { + mutableStateOf( + windowSize.windowSizeValue( + expanded = 170.dp, + compact = 105.dp + ) + ) + } + + val imageUrl = apiHostUrl.dropLast(1) + course.media.courseImage?.uri + Surface( + modifier = Modifier + .testTag("btn_course_card") + .fillMaxWidth() + .height(140.dp) + .clickable { onClick(course.courseId) } + .background(MaterialTheme.appColors.background), + ) { + Row( + modifier = Modifier + .fillMaxSize() + .background(MaterialTheme.appColors.background), + horizontalArrangement = Arrangement.spacedBy(16.dp), + verticalAlignment = Alignment.CenterVertically + ) { + AsyncImage( + model = ImageRequest.Builder(LocalContext.current) + .data(imageUrl) + .error(org.openedx.core.R.drawable.core_no_image_course) + .placeholder(org.openedx.core.R.drawable.core_no_image_course) + .build(), + contentDescription = null, + contentScale = ContentScale.Crop, + modifier = Modifier + .width(imageWidth) + .height(105.dp) + .clip(MaterialTheme.appShapes.courseImageShape) + ) + Column( + modifier = Modifier + .fillMaxWidth() + .height(105.dp), + ) { + Text( + modifier = Modifier + .testTag("txt_course_org") + .padding(top = 12.dp), + text = course.org, color = MaterialTheme.appColors.textFieldHint, + style = MaterialTheme.appTypography.labelMedium + ) + Text( + modifier = Modifier + .testTag("txt_course_title") + .fillMaxWidth() + .padding(top = 8.dp), + text = course.name, + color = MaterialTheme.appColors.textPrimary, + style = MaterialTheme.appTypography.titleSmall, + maxLines = 3, + overflow = TextOverflow.Ellipsis + ) + } + } + } +} + +@Composable +fun WarningLabel( + painter: Painter, + text: String +) { + val borderColor = if (!isSystemInDarkTheme()) { + MaterialTheme.appColors.cardViewBorder + } else { + MaterialTheme.appColors.surface + } + Box( + Modifier + .fillMaxWidth() + .shadow( + 0.dp, + MaterialTheme.appShapes.material.medium + ) + .background( + MaterialTheme.appColors.surface, + MaterialTheme.appShapes.material.medium + ) + .border( + 1.dp, + borderColor, + MaterialTheme.appShapes.material.medium + ) + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding( + horizontal = 16.dp, + vertical = 12.dp + ), + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + painter = painter, + contentDescription = null, + tint = MaterialTheme.appColors.warning + ) + Spacer(Modifier.width(12.dp)) + Text( + modifier = Modifier.testTag("txt_enroll_internet_error"), + text = text, + color = MaterialTheme.appColors.textPrimaryVariant, + style = MaterialTheme.appTypography.titleSmall + ) + } + } +} + +@Preview(uiMode = Configuration.UI_MODE_NIGHT_NO) +@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES) +@Composable +private fun WarningLabelPreview() { + OpenEdXTheme { + WarningLabel( + painter = painterResource(id = CoreR.drawable.core_ic_offline), + text = stringResource(id = R.string.discovery_no_internet_label) + ) + } +} diff --git a/course/src/main/res/drawable/course_ic_play.xml b/discovery/src/main/res/drawable/discovery_ic_play.xml similarity index 100% rename from course/src/main/res/drawable/course_ic_play.xml rename to discovery/src/main/res/drawable/discovery_ic_play.xml diff --git a/discovery/src/main/res/values-uk/strings.xml b/discovery/src/main/res/values-uk/strings.xml index cccfc5cef..f25c4ef5c 100644 --- a/discovery/src/main/res/values-uk/strings.xml +++ b/discovery/src/main/res/values-uk/strings.xml @@ -5,6 +5,10 @@ Давайте знайдемо щось нове для вас Результати пошуку Почніть вводити, щоб знайти курс + Деталі курсу + Записатися зараз + Переглянути курс + Ви не можете записатися на цей курс, оскільки термін запису вже минув. Знайдено %s курс за вашим запитом @@ -13,4 +17,6 @@ Знайдено %s курсів за вашим запитом - \ No newline at end of file + + Відтворити відео + diff --git a/discovery/src/main/res/values/strings.xml b/discovery/src/main/res/values/strings.xml index b6a6c16b8..5a02b65cf 100644 --- a/discovery/src/main/res/values/strings.xml +++ b/discovery/src/main/res/values/strings.xml @@ -6,6 +6,14 @@ Search results Start typing to find the course Explore the catalog + Course details + Enroll now + View course + You cannot enroll in this course because the enrollment date is over. + To enroll in this course, please make sure you are connected to the internet. + You have been successfully enrolled in this course. + You are already enrolled in this course. + Programs Found %s courses on your request @@ -15,4 +23,7 @@ Found %s courses on your request Found %s courses on your request + + + Play video diff --git a/discovery/src/test/java/org/openedx/discovery/presentation/NativeDiscoveryViewModelTest.kt b/discovery/src/test/java/org/openedx/discovery/presentation/NativeDiscoveryViewModelTest.kt index 3548b33ae..898a227c3 100644 --- a/discovery/src/test/java/org/openedx/discovery/presentation/NativeDiscoveryViewModelTest.kt +++ b/discovery/src/test/java/org/openedx/discovery/presentation/NativeDiscoveryViewModelTest.kt @@ -9,23 +9,27 @@ import io.mockk.verify import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.emptyFlow -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.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.data.storage.CorePreferences 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 org.openedx.discovery.domain.model.CourseList import java.net.UnknownHostException @OptIn(ExperimentalCoroutinesApi::class) @@ -66,7 +70,15 @@ class NativeDiscoveryViewModelTest { @Test fun `getCoursesList no internet connection`() = runTest { - val viewModel = NativeDiscoveryViewModel(config, networkConnection, interactor, resourceManager, analytics, appUpgradeNotifier, corePreferences) + val viewModel = NativeDiscoveryViewModel( + config, + networkConnection, + interactor, + resourceManager, + analytics, + appUpgradeNotifier, + corePreferences + ) every { networkConnection.isOnline() } returns true coEvery { interactor.getCoursesList(any(), any(), any()) } throws UnknownHostException() advanceUntilIdle() @@ -83,7 +95,15 @@ class NativeDiscoveryViewModelTest { @Test fun `getCoursesList unknown exception`() = runTest { - val viewModel = NativeDiscoveryViewModel(config, networkConnection, interactor, resourceManager, analytics, appUpgradeNotifier, corePreferences) + val viewModel = NativeDiscoveryViewModel( + config, + networkConnection, + interactor, + resourceManager, + analytics, + appUpgradeNotifier, + corePreferences + ) every { networkConnection.isOnline() } returns true coEvery { interactor.getCoursesList(any(), any(), any()) } throws Exception() advanceUntilIdle() @@ -99,7 +119,15 @@ class NativeDiscoveryViewModelTest { @Test fun `getCoursesList from cache`() = runTest { - val viewModel = NativeDiscoveryViewModel(config, networkConnection, interactor, resourceManager, analytics, appUpgradeNotifier, corePreferences) + val viewModel = NativeDiscoveryViewModel( + config, + networkConnection, + interactor, + resourceManager, + analytics, + appUpgradeNotifier, + corePreferences + ) every { networkConnection.isOnline() } returns false coEvery { interactor.getCoursesListFromCache() } returns emptyList() advanceUntilIdle() @@ -114,7 +142,15 @@ class NativeDiscoveryViewModelTest { @Test fun `getCoursesList from network with next page`() = runTest { - val viewModel = NativeDiscoveryViewModel(config, networkConnection, interactor, resourceManager, analytics, appUpgradeNotifier, corePreferences) + val viewModel = NativeDiscoveryViewModel( + config, + networkConnection, + interactor, + resourceManager, + analytics, + appUpgradeNotifier, + corePreferences + ) every { networkConnection.isOnline() } returns true coEvery { interactor.getCoursesList(any(), any(), any()) } returns CourseList( Pagination( @@ -136,7 +172,15 @@ class NativeDiscoveryViewModelTest { @Test fun `getCoursesList from network without next page`() = runTest { - val viewModel = NativeDiscoveryViewModel(config, networkConnection, interactor, resourceManager, analytics, appUpgradeNotifier, corePreferences) + val viewModel = NativeDiscoveryViewModel( + config, + networkConnection, + interactor, + resourceManager, + analytics, + appUpgradeNotifier, + corePreferences + ) every { networkConnection.isOnline() } returns true coEvery { interactor.getCoursesList(any(), any(), any()) } returns CourseList( Pagination( @@ -159,7 +203,15 @@ class NativeDiscoveryViewModelTest { @Test fun `updateData no internet connection`() = runTest { - val viewModel = NativeDiscoveryViewModel(config, networkConnection, interactor, resourceManager, analytics, appUpgradeNotifier, corePreferences) + 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() @@ -176,7 +228,15 @@ class NativeDiscoveryViewModelTest { @Test fun `updateData unknown exception`() = runTest { - val viewModel = NativeDiscoveryViewModel(config, networkConnection, interactor, resourceManager, analytics, appUpgradeNotifier, corePreferences) + 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() @@ -193,7 +253,15 @@ class NativeDiscoveryViewModelTest { @Test fun `updateData success with next page`() = runTest { - val viewModel = NativeDiscoveryViewModel(config, networkConnection, interactor, resourceManager, analytics, appUpgradeNotifier, corePreferences) + val viewModel = NativeDiscoveryViewModel( + config, + networkConnection, + interactor, + resourceManager, + analytics, + appUpgradeNotifier, + corePreferences + ) every { networkConnection.isOnline() } returns true coEvery { interactor.getCoursesList(any(), any(), any()) } returns CourseList( Pagination( @@ -216,7 +284,15 @@ class NativeDiscoveryViewModelTest { @Test fun `updateData success without next page`() = runTest { - val viewModel = NativeDiscoveryViewModel(config, networkConnection, interactor, resourceManager, analytics, appUpgradeNotifier, corePreferences) + val viewModel = NativeDiscoveryViewModel( + config, + networkConnection, + interactor, + resourceManager, + analytics, + appUpgradeNotifier, + corePreferences + ) every { networkConnection.isOnline() } returns true coEvery { interactor.getCoursesList(any(), any(), any()) } returns CourseList( Pagination( diff --git a/course/src/test/java/org/openedx/course/presentation/detail/CourseDetailsViewModelTest.kt b/discovery/src/test/java/org/openedx/discovery/presentation/detail/CourseDetailsViewModelTest.kt similarity index 91% rename from course/src/test/java/org/openedx/course/presentation/detail/CourseDetailsViewModelTest.kt rename to discovery/src/test/java/org/openedx/discovery/presentation/detail/CourseDetailsViewModelTest.kt index c5dbd2696..712a122ab 100644 --- a/course/src/test/java/org/openedx/course/presentation/detail/CourseDetailsViewModelTest.kt +++ b/discovery/src/test/java/org/openedx/discovery/presentation/detail/CourseDetailsViewModelTest.kt @@ -1,4 +1,4 @@ -package org.openedx.course.presentation.detail +package org.openedx.discovery.presentation.detail import androidx.arch.core.executor.testing.InstantTaskExecutorRule import io.mockk.coEvery @@ -25,15 +25,15 @@ 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 org.openedx.course.presentation.CourseAnalyticsEvent +import org.openedx.core.system.notifier.DiscoveryNotifier +import org.openedx.discovery.domain.interactor.DiscoveryInteractor +import org.openedx.discovery.domain.model.Course +import org.openedx.discovery.presentation.DiscoveryAnalytics +import org.openedx.discovery.presentation.DiscoveryAnalyticsEvent import java.net.UnknownHostException @OptIn(ExperimentalCoroutinesApi::class) @@ -47,10 +47,10 @@ class CourseDetailsViewModelTest { private val config = mockk() private val preferencesManager = mockk() private val resourceManager = mockk() - private val interactor = mockk() + private val interactor = mockk() private val networkConnection = mockk() - private val notifier = spyk() - private val analytics = mockk() + private val notifier = spyk() + private val analytics = mockk() private val noInternet = "Slow or no internet connection" private val somethingWrong = "Something went wrong" @@ -242,7 +242,7 @@ class CourseDetailsViewModelTest { coEvery { interactor.getCourseDetails(any()) } returns mockCourse every { analytics.logEvent( - CourseAnalyticsEvent.COURSE_ENROLL_CLICKED.eventName, + DiscoveryAnalyticsEvent.COURSE_ENROLL_CLICKED.eventName, any() ) } returns Unit @@ -254,7 +254,7 @@ class CourseDetailsViewModelTest { coVerify(exactly = 1) { interactor.enrollInACourse(any()) } verify(exactly = 1) { analytics.logEvent( - CourseAnalyticsEvent.COURSE_ENROLL_CLICKED.eventName, + DiscoveryAnalyticsEvent.COURSE_ENROLL_CLICKED.eventName, any() ) } @@ -280,13 +280,13 @@ class CourseDetailsViewModelTest { every { preferencesManager.user } returns null every { analytics.logEvent( - CourseAnalyticsEvent.COURSE_ENROLL_CLICKED.eventName, + DiscoveryAnalyticsEvent.COURSE_ENROLL_CLICKED.eventName, any() ) } returns Unit every { analytics.logEvent( - CourseAnalyticsEvent.COURSE_ENROLL_SUCCESS.eventName, + DiscoveryAnalyticsEvent.COURSE_ENROLL_SUCCESS.eventName, any() ) } returns Unit @@ -303,13 +303,13 @@ class CourseDetailsViewModelTest { coVerify(exactly = 1) { interactor.enrollInACourse(any()) } verify(exactly = 1) { analytics.logEvent( - CourseAnalyticsEvent.COURSE_ENROLL_CLICKED.eventName, + DiscoveryAnalyticsEvent.COURSE_ENROLL_CLICKED.eventName, any() ) } verify(exactly = 1) { analytics.logEvent( - CourseAnalyticsEvent.COURSE_ENROLL_SUCCESS.eventName, + DiscoveryAnalyticsEvent.COURSE_ENROLL_SUCCESS.eventName, any() ) } 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 2acf8e123..40e44e73c 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 @@ -23,12 +23,12 @@ 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.domain.model.Course +import org.openedx.discovery.domain.model.CourseList import org.openedx.discovery.presentation.DiscoveryAnalytics import java.net.UnknownHostException @@ -258,4 +258,4 @@ class CourseSearchViewModelTest { assert(viewModel.uiMessage.value == null) assert(viewModel.isUpdating.value == null) } -} \ No newline at end of file +} From 19930a91cdb4024ea860a83ef37896bceca98d70 Mon Sep 17 00:00:00 2001 From: Igor Date: Wed, 10 Apr 2024 16:18:16 +0200 Subject: [PATCH 35/39] fix: a bug when Discover and Program screens appear on the left side of the tablet (#269) --- .../discovery/presentation/NativeDiscoveryFragment.kt | 2 ++ .../discovery/presentation/WebViewDiscoveryFragment.kt | 8 +++++--- .../discovery/presentation/info/CourseInfoFragment.kt | 10 ++++++---- .../discovery/presentation/program/ProgramFragment.kt | 6 +++--- 4 files changed, 16 insertions(+), 10 deletions(-) diff --git a/discovery/src/main/java/org/openedx/discovery/presentation/NativeDiscoveryFragment.kt b/discovery/src/main/java/org/openedx/discovery/presentation/NativeDiscoveryFragment.kt index 0b43ff371..9f11c27dc 100644 --- a/discovery/src/main/java/org/openedx/discovery/presentation/NativeDiscoveryFragment.kt +++ b/discovery/src/main/java/org/openedx/discovery/presentation/NativeDiscoveryFragment.kt @@ -13,6 +13,7 @@ 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 @@ -241,6 +242,7 @@ internal fun DiscoveryScreen( horizontal = 16.dp, vertical = 32.dp, ) + .navigationBarsPadding() ) { AuthButtonsPanel( onRegisterClick = onRegisterClick, diff --git a/discovery/src/main/java/org/openedx/discovery/presentation/WebViewDiscoveryFragment.kt b/discovery/src/main/java/org/openedx/discovery/presentation/WebViewDiscoveryFragment.kt index 7c23ec690..66cd62bbe 100644 --- a/discovery/src/main/java/org/openedx/discovery/presentation/WebViewDiscoveryFragment.kt +++ b/discovery/src/main/java/org/openedx/discovery/presentation/WebViewDiscoveryFragment.kt @@ -14,6 +14,7 @@ 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.navigationBarsPadding import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.widthIn import androidx.compose.material.CircularProgressIndicator @@ -197,6 +198,7 @@ private fun WebViewDiscoveryScreen( horizontal = 16.dp, vertical = 32.dp, ) + .navigationBarsPadding() ) { AuthButtonsPanel( onRegisterClick = onRegisterClick, @@ -220,7 +222,7 @@ private fun WebViewDiscoveryScreen( } Column( - modifier = modifierScreenWidth + modifier = Modifier .fillMaxSize() .padding(it) .statusBarsInset() @@ -235,8 +237,8 @@ private fun WebViewDiscoveryScreen( Surface { Box( - modifier = Modifier - .fillMaxSize() + modifier = modifierScreenWidth + .fillMaxHeight() .background(Color.White), contentAlignment = Alignment.TopCenter ) { diff --git a/discovery/src/main/java/org/openedx/discovery/presentation/info/CourseInfoFragment.kt b/discovery/src/main/java/org/openedx/discovery/presentation/info/CourseInfoFragment.kt index 2de805731..b3b3275eb 100644 --- a/discovery/src/main/java/org/openedx/discovery/presentation/info/CourseInfoFragment.kt +++ b/discovery/src/main/java/org/openedx/discovery/presentation/info/CourseInfoFragment.kt @@ -11,6 +11,7 @@ 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.navigationBarsPadding import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.widthIn import androidx.compose.material.CircularProgressIndicator @@ -269,7 +270,7 @@ private fun CourseInfoScreen( } Column( - modifier = modifierScreenWidth + modifier = Modifier .fillMaxSize() .padding(it) .statusBarsInset() @@ -284,9 +285,10 @@ private fun CourseInfoScreen( Surface { Box( - modifier = Modifier - .fillMaxSize() - .background(Color.White), + modifier = modifierScreenWidth + .fillMaxHeight() + .background(Color.White) + .navigationBarsPadding(), contentAlignment = Alignment.TopCenter ) { if (hasInternetConnection) { diff --git a/discovery/src/main/java/org/openedx/discovery/presentation/program/ProgramFragment.kt b/discovery/src/main/java/org/openedx/discovery/presentation/program/ProgramFragment.kt index fd71e8543..98fab2557 100644 --- a/discovery/src/main/java/org/openedx/discovery/presentation/program/ProgramFragment.kt +++ b/discovery/src/main/java/org/openedx/discovery/presentation/program/ProgramFragment.kt @@ -258,7 +258,7 @@ private fun ProgramInfoScreen( } Column( - modifier = modifierScreenWidth + modifier = Modifier .fillMaxSize() .padding(it) .statusBarsInset() @@ -273,8 +273,8 @@ private fun ProgramInfoScreen( Surface { Box( - modifier = Modifier - .fillMaxSize() + modifier = modifierScreenWidth + .fillMaxHeight() .background(Color.White), contentAlignment = Alignment.TopCenter ) { From 756925da78f03184f7edd581f8acbc45bb72e55c Mon Sep 17 00:00:00 2001 From: Omer Habib <30689349+omerhabib26@users.noreply.github.com> Date: Tue, 16 Apr 2024 12:13:39 +0500 Subject: [PATCH 36/39] fix: Add authentication for primary email change through web (#285) - Handle authentication error for email mismatch case fix: LEARNER-9919 --- .../OauthRefreshTokenAuthenticator.kt | 18 ++++++++++++++---- 1 file changed, 14 insertions(+), 4 deletions(-) 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 f17677f19..3cc6b82ae 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 @@ -68,10 +68,12 @@ class OauthRefreshTokenAuthenticator( return null } - val errorCode = getErrorCode(response.peekBody(200).string()) + val errorCode = getErrorCode(response.peekBody(Long.MAX_VALUE).string()) if (errorCode != null) { when (errorCode) { - TOKEN_EXPIRED_ERROR_MESSAGE, JWT_TOKEN_EXPIRED -> { + TOKEN_EXPIRED_ERROR_MESSAGE, + JWT_TOKEN_EXPIRED, + -> { try { val newAuth = refreshAccessToken(refreshToken) if (newAuth != null) { @@ -98,7 +100,10 @@ class OauthRefreshTokenAuthenticator( } } - TOKEN_NONEXISTENT_ERROR_MESSAGE, TOKEN_INVALID_GRANT_ERROR_MESSAGE, JWT_INVALID_TOKEN -> { + 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 @@ -118,7 +123,10 @@ class OauthRefreshTokenAuthenticator( } } - DISABLED_USER_ERROR_MESSAGE, JWT_DISABLED_USER_ERROR_MESSAGE -> { + DISABLED_USER_ERROR_MESSAGE, + JWT_DISABLED_USER_ERROR_MESSAGE, + JWT_USER_EMAIL_MISMATCH, + -> { runBlocking { appNotifier.send(LogoutEvent()) } @@ -241,6 +249,8 @@ class OauthRefreshTokenAuthenticator( 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 JWT_USER_EMAIL_MISMATCH = + "Failing JWT authentication due to jwt user email mismatch with lms user email." private const val FIELD_ERROR_CODE = "error_code" private const val FIELD_DETAIL = "detail" From 9c9ee70a5ec7ec94ce3329d1e8ec2f4412bef790 Mon Sep 17 00:00:00 2001 From: Hamza Israr <71447999+HamzaIsrar12@users.noreply.github.com> Date: Tue, 16 Apr 2024 12:14:14 +0500 Subject: [PATCH 37/39] refactor: Enhance calendar event comparison for migration (#287) Enhances calendar events comparison for migration by ignoring seconds, and milliseconds improving compatibility with the new codebase. Previously, seconds and milliseconds were not considered. Now, both calendar events and course date blocks have seconds and milliseconds set to zero for precise matching, ensuring a smooth transition. Fixes: LEARNER-9924 --- .../presentation/calendarsync/CalendarManager.kt | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/course/src/main/java/org/openedx/course/presentation/calendarsync/CalendarManager.kt b/course/src/main/java/org/openedx/course/presentation/calendarsync/CalendarManager.kt index dcae8e0c2..54639e922 100644 --- a/course/src/main/java/org/openedx/course/presentation/calendarsync/CalendarManager.kt +++ b/course/src/main/java/org/openedx/course/presentation/calendarsync/CalendarManager.kt @@ -294,7 +294,18 @@ class CalendarManager( } matchedDate?.let { unit -> - if (unit.date.toCalendar().timeInMillis == dueDateInMillis) { + val dueDateCalendar = Calendar.getInstance().apply { + timeInMillis = dueDateInMillis + set(Calendar.SECOND, 0) + set(Calendar.MILLISECOND, 0) + } + + val unitDateCalendar = unit.date.toCalendar().apply { + set(Calendar.SECOND, 0) + set(Calendar.MILLISECOND, 0) + } + + if (dueDateCalendar == unitDateCalendar) { datesList.remove(unit) } else { // If any single value isn't matched, return false From 2ed9e454192687ae0cdc37c8b014af7124dbbad5 Mon Sep 17 00:00:00 2001 From: Igor Date: Tue, 16 Apr 2024 09:16:05 +0200 Subject: [PATCH 38/39] fix: fixed a bug when there was no offline panel on the discussions screen (#270) --- .../java/org/openedx/app/di/ScreenModule.kt | 10 ++- .../topics/DiscussionTopicsFragment.kt | 65 +++++++++++++++++-- .../topics/DiscussionTopicsViewModel.kt | 44 +++++-------- .../topics/DiscussionTopicsViewModelTest.kt | 48 +++++++++----- 4 files changed, 115 insertions(+), 52 deletions(-) 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 51e3a28f1..b4547a583 100644 --- a/app/src/main/java/org/openedx/app/di/ScreenModule.kt +++ b/app/src/main/java/org/openedx/app/di/ScreenModule.kt @@ -307,7 +307,15 @@ val screenModule = module { single { DiscussionRepository(get(), get(), get()) } factory { DiscussionInteractor(get()) } - viewModel { (courseId: String) -> DiscussionTopicsViewModel(get(), get(), get(), courseId) } + viewModel { (courseId: String) -> + DiscussionTopicsViewModel( + get(), + get(), + get(), + get(), + courseId + ) + } viewModel { (courseId: String, topicId: String, threadType: String) -> DiscussionThreadsViewModel( get(), 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 7d386a2e8..e6b1ddbee 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 @@ -5,18 +5,37 @@ import android.os.Bundle import android.view.LayoutInflater import android.view.ViewGroup 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.PaddingValues +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.width +import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.itemsIndexed -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.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.Modifier import androidx.compose.ui.platform.ComposeView @@ -35,11 +54,19 @@ 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.* +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.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.discussion.domain.model.Topic import org.openedx.discussion.presentation.DiscussionRouter import org.openedx.discussion.presentation.ui.ThreadItemCategory @@ -56,7 +83,7 @@ class DiscussionTopicsFragment : Fragment() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) viewModel.courseName = requireArguments().getString(ARG_COURSE_NAME, "") - viewModel.getCourseTopics() + viewModel.updateCourseTopics() } override fun onCreateView( @@ -77,9 +104,13 @@ class DiscussionTopicsFragment : Fragment() { uiState = uiState, uiMessage = uiMessage, refreshing = refreshing, - onSwipeRefresh = { + hasInternetConnection = viewModel.hasInternetConnection, + onReloadClick = { viewModel.updateCourseTopics() }, + onSwipeRefresh = { + viewModel.updateCourseTopics(withSwipeRefresh = true) + }, onItemClick = { action, data, title -> viewModel.discussionClickedEvent(action, data, title) router.navigateToDiscussionThread( @@ -130,6 +161,8 @@ private fun DiscussionTopicsScreen( uiState: DiscussionTopicsUIState, uiMessage: UIMessage?, refreshing: Boolean, + hasInternetConnection: Boolean, + onReloadClick: () -> Unit, onSearchClick: () -> Unit, onSwipeRefresh: () -> Unit, onItemClick: (String, String, String) -> Unit @@ -139,6 +172,10 @@ private fun DiscussionTopicsScreen( val pullRefreshState = rememberPullRefreshState(refreshing = refreshing, onRefresh = { onSwipeRefresh() }) + var isInternetConnectionShown by rememberSaveable { + mutableStateOf(false) + } + Scaffold( scaffoldState = scaffoldState, modifier = Modifier.fillMaxSize(), @@ -302,6 +339,20 @@ private fun DiscussionTopicsScreen( pullRefreshState, Modifier.align(Alignment.TopCenter) ) + if (!isInternetConnectionShown && !hasInternetConnection) { + OfflineModeDialog( + Modifier + .fillMaxWidth() + .align(Alignment.BottomCenter), + onDismissCLick = { + isInternetConnectionShown = true + }, + onReloadClick = { + isInternetConnectionShown = true + onReloadClick() + } + ) + } } } } @@ -322,6 +373,8 @@ private fun DiscussionTopicsScreenPreview() { uiState = DiscussionTopicsUIState.Topics(listOf(mockTopic, mockTopic)), uiMessage = null, refreshing = false, + hasInternetConnection = true, + onReloadClick = {}, onItemClick = { _, _, _ -> }, onSwipeRefresh = {}, onSearchClick = {} @@ -339,6 +392,8 @@ private fun DiscussionTopicsScreenTabletPreview() { uiState = DiscussionTopicsUIState.Topics(listOf(mockTopic, mockTopic)), uiMessage = null, refreshing = false, + hasInternetConnection = true, + onReloadClick = {}, onItemClick = { _, _, _ -> }, onSwipeRefresh = {}, onSearchClick = {} diff --git a/discussion/src/main/java/org/openedx/discussion/presentation/topics/DiscussionTopicsViewModel.kt b/discussion/src/main/java/org/openedx/discussion/presentation/topics/DiscussionTopicsViewModel.kt index 9b2bafd48..72e26405d 100644 --- a/discussion/src/main/java/org/openedx/discussion/presentation/topics/DiscussionTopicsViewModel.kt +++ b/discussion/src/main/java/org/openedx/discussion/presentation/topics/DiscussionTopicsViewModel.kt @@ -3,23 +3,25 @@ package org.openedx.discussion.presentation.topics import androidx.lifecycle.LiveData import androidx.lifecycle.MutableLiveData import androidx.lifecycle.viewModelScope +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.extension.isInternetError import org.openedx.core.system.ResourceManager +import org.openedx.core.system.connection.NetworkConnection import org.openedx.discussion.domain.interactor.DiscussionInteractor import org.openedx.discussion.presentation.DiscussionAnalytics import org.openedx.discussion.presentation.topics.DiscussionTopicsFragment.Companion.ALL_POSTS import org.openedx.discussion.presentation.topics.DiscussionTopicsFragment.Companion.FOLLOWING_POSTS import org.openedx.discussion.presentation.topics.DiscussionTopicsFragment.Companion.TOPIC -import kotlinx.coroutines.launch class DiscussionTopicsViewModel( private val interactor: DiscussionInteractor, private val resourceManager: ResourceManager, private val analytics: DiscussionAnalytics, + private val networkConnection: NetworkConnection, val courseId: String ) : BaseViewModel() { @@ -35,45 +37,31 @@ class DiscussionTopicsViewModel( val isUpdating: LiveData get() = _isUpdating + val hasInternetConnection: Boolean + get() = networkConnection.isOnline() + var courseName = "" - fun updateCourseTopics() { + fun updateCourseTopics(withSwipeRefresh: Boolean = false) { viewModelScope.launch { try { - _isUpdating.value = true - val response = interactor.getCourseTopics(courseId) - _uiState.value = DiscussionTopicsUIState.Topics(response) - } catch (e: Exception) { - if (e.isInternetError()) { - _uiMessage.value = - UIMessage.SnackBarMessage(resourceManager.getString(R.string.core_error_no_connection)) + if (withSwipeRefresh) { + _isUpdating.value = true } else { - _uiMessage.value = - UIMessage.SnackBarMessage(resourceManager.getString(R.string.core_error_unknown_error)) + _uiState.value = DiscussionTopicsUIState.Loading } - } - _isUpdating.value = false - } - } - - fun getCourseTopics() { - _uiState.value = DiscussionTopicsUIState.Loading - getCourseTopicsInternal() - } - private fun getCourseTopicsInternal() { - viewModelScope.launch { - try { val response = interactor.getCourseTopics(courseId) _uiState.value = DiscussionTopicsUIState.Topics(response) } catch (e: Exception) { - if (e.isInternetError()) { - _uiMessage.value = - UIMessage.SnackBarMessage(resourceManager.getString(R.string.core_error_no_connection)) + val errorMessage = if (e.isInternetError()) { + resourceManager.getString(R.string.core_error_no_connection) } else { - _uiMessage.value = - UIMessage.SnackBarMessage(resourceManager.getString(R.string.core_error_unknown_error)) + resourceManager.getString(R.string.core_error_unknown_error) } + _uiMessage.value = UIMessage.SnackBarMessage(errorMessage) + } finally { + _isUpdating.value = false } } } diff --git a/discussion/src/test/java/org/openedx/discussion/presentation/topics/DiscussionTopicsViewModelTest.kt b/discussion/src/test/java/org/openedx/discussion/presentation/topics/DiscussionTopicsViewModelTest.kt index 2e7998a73..48fc87e75 100644 --- a/discussion/src/test/java/org/openedx/discussion/presentation/topics/DiscussionTopicsViewModelTest.kt +++ b/discussion/src/test/java/org/openedx/discussion/presentation/topics/DiscussionTopicsViewModelTest.kt @@ -1,24 +1,29 @@ package org.openedx.discussion.presentation.topics import androidx.arch.core.executor.testing.InstantTaskExecutorRule -import org.openedx.core.R -import org.openedx.core.UIMessage -import org.openedx.core.system.ResourceManager -import org.openedx.discussion.domain.interactor.DiscussionInteractor -import org.openedx.discussion.presentation.DiscussionAnalytics 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 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.system.ResourceManager +import org.openedx.core.system.connection.NetworkConnection +import org.openedx.discussion.domain.interactor.DiscussionInteractor +import org.openedx.discussion.presentation.DiscussionAnalytics import java.net.UnknownHostException @OptIn(ExperimentalCoroutinesApi::class) @@ -32,6 +37,7 @@ class DiscussionTopicsViewModelTest { private val resourceManager = mockk() private val interactor = mockk() private val analytics = mockk() + private val networkConnection = mockk() private val noInternet = "Slow or no internet connection" private val somethingWrong = "Something went wrong" @@ -50,10 +56,11 @@ class DiscussionTopicsViewModelTest { @Test fun `getCourseTopics no internet exception`() = runTest { - val viewModel = DiscussionTopicsViewModel(interactor, resourceManager, analytics,"") + val viewModel = + DiscussionTopicsViewModel(interactor, resourceManager, analytics, networkConnection, "") coEvery { interactor.getCourseTopics(any()) } throws UnknownHostException() - viewModel.getCourseTopics() + viewModel.updateCourseTopics() advanceUntilIdle() coVerify(exactly = 1) { interactor.getCourseTopics(any()) } @@ -65,10 +72,11 @@ class DiscussionTopicsViewModelTest { @Test fun `getCourseTopics unknown exception`() = runTest { - val viewModel = DiscussionTopicsViewModel(interactor, resourceManager, analytics,"") + val viewModel = + DiscussionTopicsViewModel(interactor, resourceManager, analytics, networkConnection, "") coEvery { interactor.getCourseTopics(any()) } throws Exception() - viewModel.getCourseTopics() + viewModel.updateCourseTopics() advanceUntilIdle() coVerify(exactly = 1) { interactor.getCourseTopics(any()) } @@ -80,10 +88,11 @@ class DiscussionTopicsViewModelTest { @Test fun `getCourseTopics success`() = runTest { - val viewModel = DiscussionTopicsViewModel(interactor, resourceManager, analytics,"") + val viewModel = + DiscussionTopicsViewModel(interactor, resourceManager, analytics, networkConnection, "") coEvery { interactor.getCourseTopics(any()) } returns mockk() - viewModel.getCourseTopics() + viewModel.updateCourseTopics() advanceUntilIdle() coVerify(exactly = 1) { interactor.getCourseTopics(any()) } @@ -94,10 +103,11 @@ class DiscussionTopicsViewModelTest { @Test fun `updateCourseTopics no internet exception`() = runTest { - val viewModel = DiscussionTopicsViewModel(interactor, resourceManager, analytics,"") + val viewModel = + DiscussionTopicsViewModel(interactor, resourceManager, analytics, networkConnection, "") coEvery { interactor.getCourseTopics(any()) } throws UnknownHostException() - viewModel.updateCourseTopics() + viewModel.updateCourseTopics(withSwipeRefresh = true) advanceUntilIdle() coVerify(exactly = 1) { interactor.getCourseTopics(any()) } @@ -109,10 +119,11 @@ class DiscussionTopicsViewModelTest { @Test fun `updateCourseTopics unknown exception`() = runTest { - val viewModel = DiscussionTopicsViewModel(interactor, resourceManager, analytics,"") + val viewModel = + DiscussionTopicsViewModel(interactor, resourceManager, analytics, networkConnection, "") coEvery { interactor.getCourseTopics(any()) } throws Exception() - viewModel.updateCourseTopics() + viewModel.updateCourseTopics(withSwipeRefresh = true) advanceUntilIdle() coVerify(exactly = 1) { interactor.getCourseTopics(any()) } @@ -124,10 +135,11 @@ class DiscussionTopicsViewModelTest { @Test fun `updateCourseTopics success`() = runTest { - val viewModel = DiscussionTopicsViewModel(interactor, resourceManager, analytics,"") + val viewModel = + DiscussionTopicsViewModel(interactor, resourceManager, analytics, networkConnection, "") coEvery { interactor.getCourseTopics(any()) } returns mockk() - viewModel.updateCourseTopics() + viewModel.updateCourseTopics(withSwipeRefresh = true) advanceUntilIdle() coVerify(exactly = 1) { interactor.getCourseTopics(any()) } From 7dd29ef8346a987b79269f1d80e0531256a76e31 Mon Sep 17 00:00:00 2001 From: Max Sokolski Date: Mon, 22 Apr 2024 12:42:20 +0300 Subject: [PATCH 39/39] chore: add maintainership documentation (#291) --- catalog-info.yaml | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) create mode 100644 catalog-info.yaml diff --git a/catalog-info.yaml b/catalog-info.yaml new file mode 100644 index 000000000..87c6bc3ad --- /dev/null +++ b/catalog-info.yaml @@ -0,0 +1,16 @@ +# This file records information about this repo. Its use is described in OEP-55: +# https://open-edx-proposals.readthedocs.io/en/latest/processes/oep-0055-proc-project-maintainers.html + +apiVersion: backstage.io/v1alpha1 +kind: Component +metadata: + name: 'openedx-app-android' + description: "The mobile app for Android for the Open EdX Platform" + links: + - url: "https://github.com/openedx/openedx-app-android/tree/main/Documentation" + title: "Documentation" + icon: "PhoneAndroid" +spec: + owner: group:openedx-mobile-maintainers + type: 'mobile' + lifecycle: 'production'