From d845e5ee72a313e63522ec3e7f641f583b6f1555 Mon Sep 17 00:00:00 2001 From: Igor Date: Mon, 10 Jun 2024 10:12:40 +0200 Subject: [PATCH] feat: added the external router for deep links (#320) --- app/src/main/AndroidManifest.xml | 3 +- .../main/java/org/openedx/app/AppActivity.kt | 11 +- .../main/java/org/openedx/app/AppRouter.kt | 16 +- .../main/java/org/openedx/app/AppViewModel.kt | 8 + .../main/java/org/openedx/app/MainFragment.kt | 38 +- .../java/org/openedx/app/deeplink/DeepLink.kt | 22 + .../openedx/app/deeplink/DeepLinkRouter.kt | 507 ++++++++++++++++++ .../java/org/openedx/app/deeplink/HomeTab.kt | 8 + .../java/org/openedx/app/deeplink/Screen.kt | 20 + .../main/java/org/openedx/app/di/AppModule.kt | 2 + .../java/org/openedx/app/di/ScreenModule.kt | 2 +- .../test/java/org/openedx/AppViewModelTest.kt | 5 + .../openedx/auth/presentation/AuthRouter.kt | 7 +- .../course/presentation/CourseRouter.kt | 2 +- .../container/CourseContainerFragment.kt | 6 +- .../handouts/HandoutsWebViewFragment.kt | 20 +- .../learn/presentation/LearnFragment.kt | 29 +- .../openedx/learn/presentation/LearnTab.kt | 6 + .../detail/CourseDetailsFragment.kt | 2 +- .../presentation/info/CourseInfoViewModel.kt | 2 +- .../discussion/data/api/DiscussionApi.kt | 15 + .../data/repository/DiscussionRepository.kt | 16 +- .../domain/interactor/DiscussionInteractor.kt | 12 +- .../org/openedx/whatsnew/WhatsNewRouter.kt | 7 +- .../whatsnew/WhatsNewViewModel.kt | 3 +- 25 files changed, 730 insertions(+), 39 deletions(-) create mode 100644 app/src/main/java/org/openedx/app/deeplink/DeepLink.kt create mode 100644 app/src/main/java/org/openedx/app/deeplink/DeepLinkRouter.kt create mode 100644 app/src/main/java/org/openedx/app/deeplink/HomeTab.kt create mode 100644 app/src/main/java/org/openedx/app/deeplink/Screen.kt create mode 100644 dashboard/src/main/java/org/openedx/learn/presentation/LearnTab.kt diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 3e8282acb..efc65add4 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -41,7 +41,8 @@ android:exported="true" android:fitsSystemWindows="true" android:theme="@style/Theme.App.Starting" - android:windowSoftInputMode="adjustPan"> + android:windowSoftInputMode="adjustPan" + android:launchMode="singleInstance"> diff --git a/app/src/main/java/org/openedx/app/AppActivity.kt b/app/src/main/java/org/openedx/app/AppActivity.kt index 5ab0d0b0e..9781b0ca7 100644 --- a/app/src/main/java/org/openedx/app/AppActivity.kt +++ b/app/src/main/java/org/openedx/app/AppActivity.kt @@ -18,6 +18,7 @@ import io.branch.referral.Branch.BranchUniversalReferralInitListener import org.koin.android.ext.android.inject import org.koin.androidx.viewmodel.ext.android.viewModel import org.openedx.app.databinding.ActivityAppBinding +import org.openedx.app.deeplink.DeepLink import org.openedx.auth.presentation.logistration.LogistrationFragment import org.openedx.auth.presentation.signin.SignInFragment import org.openedx.core.data.storage.CorePreferences @@ -145,10 +146,14 @@ class AppActivity : AppCompatActivity(), InsetHolder, WindowSizeHolder { super.onStart() if (viewModel.isBranchEnabled) { - val callback = BranchUniversalReferralInitListener { _, linkProperties, error -> - if (linkProperties != null) { + val callback = BranchUniversalReferralInitListener { branchUniversalObject, _, error -> + if (branchUniversalObject?.contentMetadata?.customMetadata != null) { branchLogger.i { "Branch init complete." } - branchLogger.i { linkProperties.controlParams.toString() } + branchLogger.i { branchUniversalObject.contentMetadata.customMetadata.toString() } + viewModel.makeExternalRoute( + fm = supportFragmentManager, + deepLink = DeepLink(branchUniversalObject.contentMetadata.customMetadata) + ) } else if (error != null) { branchLogger.e { "Branch init failed. Caused by -" + error.message } } diff --git a/app/src/main/java/org/openedx/app/AppRouter.kt b/app/src/main/java/org/openedx/app/AppRouter.kt index fe4394cde..99eb919dc 100644 --- a/app/src/main/java/org/openedx/app/AppRouter.kt +++ b/app/src/main/java/org/openedx/app/AppRouter.kt @@ -59,10 +59,15 @@ class AppRouter : AuthRouter, DiscoveryRouter, DashboardRouter, CourseRouter, Di ProfileRouter, AppUpgradeRouter, WhatsNewRouter { //region AuthRouter - override fun navigateToMain(fm: FragmentManager, courseId: String?, infoType: String?) { + override fun navigateToMain( + fm: FragmentManager, + courseId: String?, + infoType: String?, + openTab: String + ) { fm.popBackStack() fm.beginTransaction() - .replace(R.id.container, MainFragment.newInstance(courseId, infoType)) + .replace(R.id.container, MainFragment.newInstance(courseId, infoType, openTab)) .commit() } @@ -286,12 +291,11 @@ class AppRouter : AuthRouter, DiscoveryRouter, DashboardRouter, CourseRouter, Di override fun navigateToHandoutsWebView( fm: FragmentManager, courseId: String, - title: String, type: HandoutsType, ) { replaceFragmentWithBackStack( fm, - HandoutsWebViewFragment.newInstance(title, type.name, courseId) + HandoutsWebViewFragment.newInstance(type.name, courseId) ) } //endregion @@ -409,6 +413,10 @@ class AppRouter : AuthRouter, DiscoveryRouter, DashboardRouter, CourseRouter, Di } //endregion + fun getVisibleFragment(fm: FragmentManager): Fragment? { + return fm.fragments.firstOrNull { it.isVisible } + } + private fun replaceFragmentWithBackStack(fm: FragmentManager, fragment: Fragment) { fm.beginTransaction() .replace(R.id.container, fragment, fragment.javaClass.simpleName) diff --git a/app/src/main/java/org/openedx/app/AppViewModel.kt b/app/src/main/java/org/openedx/app/AppViewModel.kt index c18e48026..3fc49859f 100644 --- a/app/src/main/java/org/openedx/app/AppViewModel.kt +++ b/app/src/main/java/org/openedx/app/AppViewModel.kt @@ -1,5 +1,6 @@ package org.openedx.app +import androidx.fragment.app.FragmentManager import androidx.lifecycle.LifecycleOwner import androidx.lifecycle.LiveData import androidx.lifecycle.viewModelScope @@ -7,6 +8,8 @@ import androidx.room.RoomDatabase import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.launch import kotlinx.coroutines.withContext +import org.openedx.app.deeplink.DeepLink +import org.openedx.app.deeplink.DeepLinkRouter import org.openedx.app.system.notifier.AppNotifier import org.openedx.app.system.notifier.LogoutEvent import org.openedx.core.BaseViewModel @@ -22,6 +25,7 @@ class AppViewModel( private val preferencesManager: CorePreferences, private val dispatcher: CoroutineDispatcher, private val analytics: AppAnalytics, + private val deepLinkRouter: DeepLinkRouter, private val fileUtil: FileUtil, ) : BaseViewModel() { @@ -71,6 +75,10 @@ class AppViewModel( preferencesManager.canResetAppDirectory = false } + fun makeExternalRoute(fm: FragmentManager, deepLink: DeepLink) { + deepLinkRouter.makeRoute(fm, deepLink) + } + 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 7087fee8f..62857ee9f 100644 --- a/app/src/main/java/org/openedx/app/MainFragment.kt +++ b/app/src/main/java/org/openedx/app/MainFragment.kt @@ -12,11 +12,13 @@ import kotlinx.coroutines.launch import org.koin.android.ext.android.inject import org.koin.androidx.viewmodel.ext.android.viewModel import org.openedx.app.databinding.FragmentMainBinding +import org.openedx.app.deeplink.HomeTab import org.openedx.core.adapter.NavigationFragmentAdapter import org.openedx.core.presentation.global.app_upgrade.UpgradeRequiredFragment import org.openedx.core.presentation.global.viewBinding import org.openedx.discovery.presentation.DiscoveryRouter import org.openedx.learn.presentation.LearnFragment +import org.openedx.learn.presentation.LearnTab import org.openedx.profile.presentation.profile.ProfileFragment class MainFragment : Fragment(R.layout.fragment_main) { @@ -60,8 +62,6 @@ class MainFragment : Fragment(R.layout.fragment_main) { } true } - // Trigger click event for the first tab on initial load - binding.bottomNavView.selectedItemId = binding.bottomNavView.selectedItemId viewModel.isBottomBarEnabled.observe(viewLifecycleOwner) { isBottomBarEnabled -> enableBottomBar(isBottomBarEnabled) @@ -89,6 +89,22 @@ class MainFragment : Fragment(R.layout.fragment_main) { putString(ARG_COURSE_ID, "") putString(ARG_INFO_TYPE, "") } + + when (requireArguments().getString(ARG_OPEN_TAB, HomeTab.LEARN.name)) { + HomeTab.LEARN.name, + HomeTab.PROGRAMS.name -> { + binding.bottomNavView.selectedItemId = R.id.fragmentLearn + } + + HomeTab.DISCOVER.name -> { + binding.bottomNavView.selectedItemId = R.id.fragmentDiscover + } + + HomeTab.PROFILE.name -> { + binding.bottomNavView.selectedItemId = R.id.fragmentProfile + } + } + requireArguments().remove(ARG_OPEN_TAB) } } @@ -96,8 +112,14 @@ class MainFragment : Fragment(R.layout.fragment_main) { binding.viewPager.orientation = ViewPager2.ORIENTATION_HORIZONTAL binding.viewPager.offscreenPageLimit = 4 + val openTab = requireArguments().getString(ARG_OPEN_TAB, HomeTab.LEARN.name) + val learnTab = if (openTab == HomeTab.PROGRAMS.name) { + LearnTab.PROGRAMS + } else { + LearnTab.COURSES + } adapter = NavigationFragmentAdapter(this).apply { - addFragment(LearnFragment()) + addFragment(LearnFragment.newInstance(openTab = learnTab.name)) addFragment(viewModel.getDiscoveryFragment) addFragment(ProfileFragment()) } @@ -114,11 +136,17 @@ class MainFragment : Fragment(R.layout.fragment_main) { companion object { private const val ARG_COURSE_ID = "courseId" private const val ARG_INFO_TYPE = "info_type" - fun newInstance(courseId: String? = null, infoType: String? = null): MainFragment { + private const val ARG_OPEN_TAB = "open_tab" + fun newInstance( + courseId: String? = null, + infoType: String? = null, + openTab: String = HomeTab.LEARN.name + ): MainFragment { val fragment = MainFragment() fragment.arguments = bundleOf( ARG_COURSE_ID to courseId, - ARG_INFO_TYPE to infoType + ARG_INFO_TYPE to infoType, + ARG_OPEN_TAB to openTab ) return fragment } diff --git a/app/src/main/java/org/openedx/app/deeplink/DeepLink.kt b/app/src/main/java/org/openedx/app/deeplink/DeepLink.kt new file mode 100644 index 000000000..2b65a92b1 --- /dev/null +++ b/app/src/main/java/org/openedx/app/deeplink/DeepLink.kt @@ -0,0 +1,22 @@ +package org.openedx.app.deeplink + +class DeepLink(params: Map) { + + val screenName = params[Keys.SCREEN_NAME.value] + val courseId = params[Keys.COURSE_ID.value] + val pathId = params[Keys.PATH_ID.value] + val componentId = params[Keys.COMPONENT_ID.value] + val topicId = params[Keys.TOPIC_ID.value] + val threadId = params[Keys.THREAD_ID.value] + val commentId = params[Keys.COMMENT_ID.value] + + enum class Keys(val value: String) { + SCREEN_NAME("screen_name"), + COURSE_ID("course_id"), + PATH_ID("path_id"), + COMPONENT_ID("component_id"), + TOPIC_ID("topic_id"), + THREAD_ID("thread_id"), + COMMENT_ID("comment_id") + } +} diff --git a/app/src/main/java/org/openedx/app/deeplink/DeepLinkRouter.kt b/app/src/main/java/org/openedx/app/deeplink/DeepLinkRouter.kt new file mode 100644 index 000000000..02bc5cd0e --- /dev/null +++ b/app/src/main/java/org/openedx/app/deeplink/DeepLinkRouter.kt @@ -0,0 +1,507 @@ +package org.openedx.app.deeplink + +import androidx.fragment.app.FragmentManager +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import org.openedx.app.AppRouter +import org.openedx.app.MainFragment +import org.openedx.app.R +import org.openedx.auth.presentation.signin.SignInFragment +import org.openedx.core.FragmentViewType +import org.openedx.core.config.Config +import org.openedx.core.data.storage.CorePreferences +import org.openedx.core.presentation.course.CourseViewMode +import org.openedx.course.domain.interactor.CourseInteractor +import org.openedx.course.presentation.handouts.HandoutsType +import org.openedx.discovery.presentation.catalog.WebViewLink +import org.openedx.discussion.domain.interactor.DiscussionInteractor +import org.openedx.discussion.presentation.topics.DiscussionTopicsViewModel +import kotlin.coroutines.CoroutineContext + +class DeepLinkRouter( + private val config: Config, + private val appRouter: AppRouter, + private val corePreferences: CorePreferences, + private val courseInteractor: CourseInteractor, + private val discussionInteractor: DiscussionInteractor +) : CoroutineScope { + + override val coroutineContext: CoroutineContext + get() = Dispatchers.Default + + private val isUserLoggedIn + get() = corePreferences.user != null + + fun makeRoute(fm: FragmentManager, deepLink: DeepLink) { + val screenName = deepLink.screenName ?: return + when (screenName) { + // Discovery + Screen.DISCOVERY.screenName -> { + navigateToDiscoveryScreen(fm = fm) + return + } + + Screen.DISCOVERY_COURSE_DETAIL.screenName -> { + navigateToCourseDetail( + fm = fm, + deepLink = deepLink + ) + return + } + + Screen.DISCOVERY_PROGRAM_DETAIL.screenName -> { + navigateToProgramDetail( + fm = fm, + deepLink = deepLink + ) + return + } + } + + if (!isUserLoggedIn) { + navigateToSignIn(fm = fm) + return + } + + when (screenName) { + // Course + Screen.COURSE_DASHBOARD.screenName -> { + navigateToDashboard(fm = fm) + navigateToCourseDashboard( + fm = fm, + deepLink = deepLink + ) + } + + Screen.COURSE_VIDEOS.screenName -> { + navigateToDashboard(fm = fm) + navigateToCourseVideos( + fm = fm, + deepLink = deepLink + ) + } + + Screen.COURSE_DATES.screenName -> { + navigateToDashboard(fm = fm) + navigateToCourseDates( + fm = fm, + deepLink = deepLink + ) + } + + Screen.COURSE_DISCUSSION.screenName -> { + navigateToDashboard(fm = fm) + navigateToCourseDiscussion( + fm = fm, + deepLink = deepLink + ) + } + + Screen.COURSE_HANDOUT.screenName -> { + navigateToDashboard(fm = fm) + navigateToCourseMore( + fm = fm, + deepLink = deepLink + ) + navigateToCourseHandout( + fm = fm, + deepLink = deepLink + ) + } + + Screen.COURSE_ANNOUNCEMENT.screenName -> { + navigateToDashboard(fm = fm) + navigateToCourseMore( + fm = fm, + deepLink = deepLink + ) + navigateToCourseAnnouncement( + fm = fm, + deepLink = deepLink + ) + } + + Screen.COURSE_COMPONENT.screenName -> { + navigateToDashboard(fm = fm) + navigateToCourseDashboard( + fm = fm, + deepLink = deepLink + ) + navigateToCourseComponent( + fm = fm, + deepLink = deepLink + ) + } + + // Program + Screen.PROGRAM.screenName -> { + navigateToProgram( + fm = fm, + deepLink = deepLink + ) + } + + // Discussions + Screen.DISCUSSION_TOPIC.screenName -> { + navigateToDashboard(fm = fm) + navigateToCourseDiscussion( + fm = fm, + deepLink = deepLink + ) + navigateToDiscussionTopic( + fm = fm, + deepLink = deepLink + ) + } + + Screen.DISCUSSION_POST.screenName -> { + navigateToDashboard(fm = fm) + navigateToCourseDiscussion( + fm = fm, + deepLink = deepLink + ) + navigateToDiscussionPost( + fm = fm, + deepLink = deepLink + ) + } + + Screen.DISCUSSION_COMMENT.screenName -> { + navigateToDashboard(fm = fm) + navigateToCourseDiscussion( + fm = fm, + deepLink = deepLink + ) + navigateToDiscussionComment( + fm = fm, + deepLink = deepLink + ) + } + + // Profile + Screen.PROFILE.screenName, + Screen.USER_PROFILE.screenName -> { + navigateToProfile(fm = fm) + } + } + } + + // Returns true if there was a successful redirect to the discovery screen + private fun navigateToDiscoveryScreen(fm: FragmentManager): Boolean { + return if (isUserLoggedIn) { + fm.popBackStack() + fm.beginTransaction() + .replace(R.id.container, MainFragment.newInstance(openTab = "DISCOVER")) + .commitNow() + true + } else if (!config.isPreLoginExperienceEnabled()) { + navigateToSignIn(fm = fm) + false + } else if (config.getDiscoveryConfig().isViewTypeWebView()) { + appRouter.navigateToWebDiscoverCourses( + fm = fm, + querySearch = "" + ) + true + } else { + appRouter.navigateToNativeDiscoverCourses( + fm = fm, + querySearch = "" + ) + true + } + } + + private fun navigateToCourseDetail(fm: FragmentManager, deepLink: DeepLink) { + deepLink.courseId?.let { courseId -> + if (navigateToDiscoveryScreen(fm = fm)) { + appRouter.navigateToCourseInfo( + fm = fm, + courseId = courseId, + infoType = WebViewLink.Authority.COURSE_INFO.name + ) + } + } + } + + private fun navigateToProgramDetail(fm: FragmentManager, deepLink: DeepLink) { + deepLink.pathId?.let { pathId -> + if (navigateToDiscoveryScreen(fm = fm)) { + appRouter.navigateToCourseInfo( + fm = fm, + courseId = pathId, + infoType = WebViewLink.Authority.PROGRAM_INFO.name + ) + } + } + } + + private fun navigateToSignIn(fm: FragmentManager) { + if (appRouter.getVisibleFragment(fm = fm) !is SignInFragment) { + appRouter.navigateToSignIn( + fm = fm, + courseId = null, + infoType = null + ) + } + } + + private fun navigateToCourseDashboard(fm: FragmentManager, deepLink: DeepLink) { + deepLink.courseId?.let { courseId -> + appRouter.navigateToCourseOutline( + fm = fm, + courseId = courseId, + courseTitle = "", + enrollmentMode = "" + ) + } + } + + private fun navigateToCourseVideos(fm: FragmentManager, deepLink: DeepLink) { + deepLink.courseId?.let { courseId -> + appRouter.navigateToCourseOutline( + fm = fm, + courseId = courseId, + courseTitle = "", + enrollmentMode = "", + openTab = "VIDEOS" + ) + } + } + + private fun navigateToCourseDates(fm: FragmentManager, deepLink: DeepLink) { + deepLink.courseId?.let { courseId -> + appRouter.navigateToCourseOutline( + fm = fm, + courseId = courseId, + courseTitle = "", + enrollmentMode = "", + openTab = "DATES" + ) + } + } + + private fun navigateToCourseDiscussion(fm: FragmentManager, deepLink: DeepLink) { + deepLink.courseId?.let { courseId -> + appRouter.navigateToCourseOutline( + fm = fm, + courseId = courseId, + courseTitle = "", + enrollmentMode = "", + openTab = "DISCUSSIONS" + ) + } + } + + private fun navigateToCourseMore(fm: FragmentManager, deepLink: DeepLink) { + deepLink.courseId?.let { courseId -> + appRouter.navigateToCourseOutline( + fm = fm, + courseId = courseId, + courseTitle = "", + enrollmentMode = "", + openTab = "MORE" + ) + } + } + + private fun navigateToCourseHandout(fm: FragmentManager, deepLink: DeepLink) { + deepLink.courseId?.let { courseId -> + appRouter.navigateToHandoutsWebView( + fm = fm, + courseId = courseId, + type = HandoutsType.Handouts + ) + } + } + + private fun navigateToCourseAnnouncement(fm: FragmentManager, deepLink: DeepLink) { + deepLink.courseId?.let { courseId -> + appRouter.navigateToHandoutsWebView( + fm = fm, + courseId = courseId, + type = HandoutsType.Announcements + ) + } + } + + private fun navigateToCourseComponent(fm: FragmentManager, deepLink: DeepLink) { + deepLink.courseId?.let { courseId -> + deepLink.componentId?.let { componentId -> + launch { + try { + val courseStructure = courseInteractor.getCourseStructure(courseId) + courseStructure.blockData + .find { it.descendants.contains(componentId) }?.let { block -> + appRouter.navigateToCourseContainer( + fm = fm, + courseId = courseId, + unitId = block.id, + componentId = componentId, + mode = CourseViewMode.FULL + ) + } + } catch (e: Exception) { + e.printStackTrace() + } + } + } + } + } + + private fun navigateToProgram(fm: FragmentManager, deepLink: DeepLink) { + val pathId = deepLink.pathId + if (pathId == null) { + navigateToPrograms(fm = fm) + } else { + appRouter.navigateToEnrolledProgramInfo( + fm = fm, + pathId = pathId + ) + } + } + + private fun navigateToDiscussionTopic(fm: FragmentManager, deepLink: DeepLink) { + deepLink.courseId?.let { courseId -> + deepLink.topicId?.let { topicId -> + launch { + try { + discussionInteractor.getCourseTopics(courseId) + .find { it.id == topicId }?.let { topic -> + launch(Dispatchers.Main) { + appRouter.navigateToDiscussionThread( + fm = fm, + action = DiscussionTopicsViewModel.TOPIC, + courseId = courseId, + topicId = topicId, + title = topic.name, + viewType = FragmentViewType.FULL_CONTENT + ) + } + } + } catch (e: Exception) { + e.printStackTrace() + } + } + } + } + } + + private fun navigateToDiscussionPost(fm: FragmentManager, deepLink: DeepLink) { + deepLink.courseId?.let { courseId -> + deepLink.topicId?.let { topicId -> + deepLink.threadId?.let { threadId -> + launch { + try { + discussionInteractor.getCourseTopics(courseId) + .find { it.id == topicId }?.let { topic -> + launch(Dispatchers.Main) { + appRouter.navigateToDiscussionThread( + fm = fm, + action = DiscussionTopicsViewModel.TOPIC, + courseId = courseId, + topicId = topicId, + title = topic.name, + viewType = FragmentViewType.FULL_CONTENT + ) + } + } + val thread = discussionInteractor.getThread( + threadId, + courseId, + topicId + ) + launch(Dispatchers.Main) { + appRouter.navigateToDiscussionComments( + fm = fm, + thread = thread + ) + } + } catch (e: Exception) { + e.printStackTrace() + } + } + } + } + } + } + + private fun navigateToDiscussionComment(fm: FragmentManager, deepLink: DeepLink) { + deepLink.courseId?.let { courseId -> + deepLink.topicId?.let { topicId -> + deepLink.threadId?.let { threadId -> + deepLink.commentId?.let { commentId -> + launch { + try { + discussionInteractor.getCourseTopics(courseId) + .find { it.id == topicId }?.let { topic -> + launch(Dispatchers.Main) { + appRouter.navigateToDiscussionThread( + fm = fm, + action = DiscussionTopicsViewModel.TOPIC, + courseId = courseId, + topicId = topicId, + title = topic.name, + viewType = FragmentViewType.FULL_CONTENT + ) + } + } + val thread = discussionInteractor.getThread( + threadId, + courseId, + topicId + ) + launch(Dispatchers.Main) { + appRouter.navigateToDiscussionComments( + fm = fm, + thread = thread + ) + } + val commentsData = discussionInteractor.getThreadComment(commentId) + commentsData.results.firstOrNull()?.let { comment -> + launch(Dispatchers.Main) { + appRouter.navigateToDiscussionResponses( + fm = fm, + comment = comment, + isClosed = false + ) + } + } + } catch (e: Exception) { + e.printStackTrace() + } + } + } + } + } + } + } + + private fun navigateToDashboard(fm: FragmentManager) { + appRouter.navigateToMain( + fm = fm, + courseId = null, + infoType = null, + openTab = "LEARN" + ) + } + + private fun navigateToPrograms(fm: FragmentManager) { + appRouter.navigateToMain( + fm = fm, + courseId = null, + infoType = null, + openTab = "PROGRAMS" + ) + } + + private fun navigateToProfile(fm: FragmentManager) { + appRouter.navigateToMain( + fm = fm, + courseId = null, + infoType = null, + openTab = "PROFILE" + ) + } +} diff --git a/app/src/main/java/org/openedx/app/deeplink/HomeTab.kt b/app/src/main/java/org/openedx/app/deeplink/HomeTab.kt new file mode 100644 index 000000000..c020cf636 --- /dev/null +++ b/app/src/main/java/org/openedx/app/deeplink/HomeTab.kt @@ -0,0 +1,8 @@ +package org.openedx.app.deeplink + +enum class HomeTab { + LEARN, + PROGRAMS, + DISCOVER, + PROFILE +} diff --git a/app/src/main/java/org/openedx/app/deeplink/Screen.kt b/app/src/main/java/org/openedx/app/deeplink/Screen.kt new file mode 100644 index 000000000..e877649e8 --- /dev/null +++ b/app/src/main/java/org/openedx/app/deeplink/Screen.kt @@ -0,0 +1,20 @@ +package org.openedx.app.deeplink + +enum class Screen(val screenName: String) { + DISCOVERY("discovery"), + DISCOVERY_COURSE_DETAIL("discovery_course_detail"), + DISCOVERY_PROGRAM_DETAIL("discovery_program_detail"), + COURSE_DASHBOARD("course_dashboard"), + COURSE_VIDEOS("course_videos"), + COURSE_DISCUSSION("course_discussion"), + COURSE_DATES("course_dates"), + COURSE_HANDOUT("course_handout"), + COURSE_ANNOUNCEMENT("course_announcement"), + COURSE_COMPONENT("course_component"), + PROGRAM("program"), + DISCUSSION_TOPIC("discussion_topic"), + DISCUSSION_POST("discussion_post"), + DISCUSSION_COMMENT("discussion_comment"), + PROFILE("profile"), + USER_PROFILE("user_profile"), +} 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 a5ec76b37..1d1dc7e0c 100644 --- a/app/src/main/java/org/openedx/app/di/AppModule.kt +++ b/app/src/main/java/org/openedx/app/di/AppModule.kt @@ -12,6 +12,7 @@ import org.koin.core.qualifier.named import org.koin.dsl.module import org.openedx.app.AnalyticsManager import org.openedx.app.AppAnalytics +import org.openedx.app.deeplink.DeepLinkRouter import org.openedx.app.AppRouter import org.openedx.app.BuildConfig import org.openedx.app.data.storage.PreferencesManager @@ -109,6 +110,7 @@ val appModule = module { single { get() } single { get() } single { get() } + single { DeepLinkRouter(get(), get(), get(), get(), get()) } single { NetworkConnection(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 393b16248..0c2c85474 100644 --- a/app/src/main/java/org/openedx/app/di/ScreenModule.kt +++ b/app/src/main/java/org/openedx/app/di/ScreenModule.kt @@ -67,7 +67,7 @@ import org.openedx.whatsnew.presentation.whatsnew.WhatsNewViewModel val screenModule = module { - viewModel { AppViewModel(get(), get(), get(), get(), get(named("IODispatcher")), get(), get()) } + viewModel { AppViewModel(get(), get(), get(), get(), get(named("IODispatcher")), get(), get(), get()) } viewModel { MainViewModel(get(), get(), get()) } factory { AuthRepository(get(), get(), get()) } diff --git a/app/src/test/java/org/openedx/AppViewModelTest.kt b/app/src/test/java/org/openedx/AppViewModelTest.kt index c81c9c2e5..35a2d3d96 100644 --- a/app/src/test/java/org/openedx/AppViewModelTest.kt +++ b/app/src/test/java/org/openedx/AppViewModelTest.kt @@ -21,6 +21,7 @@ import org.junit.Rule import org.junit.Test import org.junit.rules.TestRule import org.openedx.app.AppAnalytics +import org.openedx.app.deeplink.DeepLinkRouter import org.openedx.app.AppViewModel import org.openedx.app.data.storage.PreferencesManager import org.openedx.app.room.AppDatabase @@ -44,6 +45,7 @@ class AppViewModelTest { private val preferencesManager = mockk() private val analytics = mockk() private val fileUtil = mockk() + private val deepLinkRouter = mockk() private val user = User(0, "", "", "") @@ -71,6 +73,7 @@ class AppViewModelTest { preferencesManager, dispatcher, analytics, + deepLinkRouter, fileUtil ) @@ -102,6 +105,7 @@ class AppViewModelTest { preferencesManager, dispatcher, analytics, + deepLinkRouter, fileUtil ) @@ -135,6 +139,7 @@ class AppViewModelTest { preferencesManager, dispatcher, analytics, + deepLinkRouter, fileUtil ) diff --git a/auth/src/main/java/org/openedx/auth/presentation/AuthRouter.kt b/auth/src/main/java/org/openedx/auth/presentation/AuthRouter.kt index 9b1266119..945acf02e 100644 --- a/auth/src/main/java/org/openedx/auth/presentation/AuthRouter.kt +++ b/auth/src/main/java/org/openedx/auth/presentation/AuthRouter.kt @@ -4,7 +4,12 @@ import androidx.fragment.app.FragmentManager interface AuthRouter { - fun navigateToMain(fm: FragmentManager, courseId: String?, infoType: String?) + fun navigateToMain( + fm: FragmentManager, + courseId: String?, + infoType: String?, + openTab: String = "" + ) fun navigateToSignIn(fm: FragmentManager, courseId: String?, infoType: String?) diff --git a/course/src/main/java/org/openedx/course/presentation/CourseRouter.kt b/course/src/main/java/org/openedx/course/presentation/CourseRouter.kt index 9b34e7617..3b59be61d 100644 --- a/course/src/main/java/org/openedx/course/presentation/CourseRouter.kt +++ b/course/src/main/java/org/openedx/course/presentation/CourseRouter.kt @@ -56,7 +56,7 @@ interface CourseRouter { ) fun navigateToHandoutsWebView( - fm: FragmentManager, courseId: String, title: String, type: HandoutsType + fm: FragmentManager, courseId: String, type: HandoutsType ) fun navigateToDownloadQueue(fm: FragmentManager, descendants: List = arrayListOf()) 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 4df1fcf64..49d6b8cae 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 @@ -140,7 +140,7 @@ class CourseContainerFragment : Fragment(R.layout.fragment_course_container) { snackBar?.show() } - lifecycleScope.launch { + viewLifecycleOwner.lifecycleScope.launch { viewModel.showProgress.collect { binding.progressBar.isVisible = it } @@ -522,15 +522,12 @@ fun DashboardPager( } CourseContainerTab.MORE -> { - val announcementsString = stringResource(id = R.string.course_announcements) - val handoutsString = stringResource(id = R.string.course_handouts) HandoutsScreen( windowSize = windowSize, onHandoutsClick = { viewModel.courseRouter.navigateToHandoutsWebView( fragmentManager, bundle.getString(CourseContainerFragment.ARG_COURSE_ID, ""), - handoutsString, HandoutsType.Handouts ) }, @@ -538,7 +535,6 @@ fun DashboardPager( viewModel.courseRouter.navigateToHandoutsWebView( fragmentManager, bundle.getString(CourseContainerFragment.ARG_COURSE_ID, ""), - announcementsString, HandoutsType.Announcements ) }) 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 7c9d3615e..16cc67b84 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.R import org.openedx.course.presentation.CourseAnalyticsEvent class HandoutsWebViewFragment : Fragment() { @@ -39,6 +40,15 @@ class HandoutsWebViewFragment : Fragment() { savedInstanceState: Bundle?, ) = ComposeView(requireContext()).apply { setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed) + + val title = if (HandoutsType.valueOf(viewModel.handoutsType) == HandoutsType.Handouts) { + viewModel.logEvent(CourseAnalyticsEvent.HANDOUTS) + getString(R.string.course_handouts) + } else { + viewModel.logEvent(CourseAnalyticsEvent.ANNOUNCEMENTS) + getString(R.string.course_announcements) + } + setContent { OpenEdXTheme { val windowSize = rememberWindowSize() @@ -50,7 +60,7 @@ class HandoutsWebViewFragment : Fragment() { WebContentScreen( windowSize = windowSize, apiHostUrl = viewModel.apiHostUrl, - title = requireArguments().getString(ARG_TITLE, ""), + title = title, htmlBody = viewModel.injectDarkMode( htmlBody, colorBackgroundValue, @@ -61,26 +71,18 @@ class HandoutsWebViewFragment : Fragment() { }) } } - if (HandoutsType.valueOf(viewModel.handoutsType) == HandoutsType.Handouts) { - viewModel.logEvent(CourseAnalyticsEvent.HANDOUTS) - } else { - viewModel.logEvent(CourseAnalyticsEvent.ANNOUNCEMENTS) - } } companion object { - private val ARG_TITLE = "argTitle" private val ARG_TYPE = "argType" private val ARG_COURSE_ID = "argCourse" fun newInstance( - title: String, type: String, courseId: String, ): HandoutsWebViewFragment { val fragment = HandoutsWebViewFragment() fragment.arguments = bundleOf( - ARG_TITLE to title, ARG_TYPE to type, ARG_COURSE_ID to courseId ) diff --git a/dashboard/src/main/java/org/openedx/learn/presentation/LearnFragment.kt b/dashboard/src/main/java/org/openedx/learn/presentation/LearnFragment.kt index 7a79f3c2e..1fc574f41 100644 --- a/dashboard/src/main/java/org/openedx/learn/presentation/LearnFragment.kt +++ b/dashboard/src/main/java/org/openedx/learn/presentation/LearnFragment.kt @@ -36,6 +36,7 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp +import androidx.core.os.bundleOf import androidx.fragment.app.Fragment import androidx.fragment.app.FragmentManager import androidx.viewpager2.widget.ViewPager2 @@ -64,15 +65,22 @@ class LearnFragment : Fragment(R.layout.fragment_learn) { override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) + initViewPager() + val openTab = requireArguments().getString(ARG_OPEN_TAB, LearnTab.COURSES.name) + val defaultLearnType = if (openTab == LearnTab.PROGRAMS.name) { + LearnType.PROGRAMS + } else { + LearnType.COURSES + } binding.header.setContent { OpenEdXTheme { Header( fragmentManager = requireParentFragment().parentFragmentManager, + defaultLearnType = defaultLearnType, viewPager = binding.viewPager ) } } - initViewPager() } private fun initViewPager() { @@ -86,11 +94,25 @@ class LearnFragment : Fragment(R.layout.fragment_learn) { binding.viewPager.adapter = adapter binding.viewPager.setUserInputEnabled(false) } + + companion object { + private const val ARG_OPEN_TAB = "open_tab" + fun newInstance( + openTab: String = LearnTab.COURSES.name + ): LearnFragment { + val fragment = LearnFragment() + fragment.arguments = bundleOf( + ARG_OPEN_TAB to openTab + ) + return fragment + } + } } @Composable private fun Header( fragmentManager: FragmentManager, + defaultLearnType: LearnType, viewPager: ViewPager2, ) { val viewModel: LearnViewModel = koinViewModel() @@ -123,6 +145,7 @@ private fun Header( modifier = Modifier .align(Alignment.Start) .padding(horizontal = 16.dp), + defaultLearnType = defaultLearnType, viewPager = viewPager ) } @@ -166,10 +189,11 @@ private fun Title( @Composable private fun LearnDropdownMenu( modifier: Modifier = Modifier, + defaultLearnType: LearnType, viewPager: ViewPager2, ) { var expanded by remember { mutableStateOf(false) } - var currentValue by remember { mutableStateOf(LearnType.COURSES) } + var currentValue by remember { mutableStateOf(defaultLearnType) } val iconRotation by animateFloatAsState( targetValue = if (expanded) 180f else 0f, label = "" @@ -270,6 +294,7 @@ private fun LearnDropdownMenuPreview() { OpenEdXTheme { val context = LocalContext.current LearnDropdownMenu( + defaultLearnType = LearnType.COURSES, viewPager = ViewPager2(context) ) } diff --git a/dashboard/src/main/java/org/openedx/learn/presentation/LearnTab.kt b/dashboard/src/main/java/org/openedx/learn/presentation/LearnTab.kt new file mode 100644 index 000000000..c7498298a --- /dev/null +++ b/dashboard/src/main/java/org/openedx/learn/presentation/LearnTab.kt @@ -0,0 +1,6 @@ +package org.openedx.learn.presentation + +enum class LearnTab { + COURSES, + PROGRAMS +} diff --git a/discovery/src/main/java/org/openedx/discovery/presentation/detail/CourseDetailsFragment.kt b/discovery/src/main/java/org/openedx/discovery/presentation/detail/CourseDetailsFragment.kt index 0060199da..4c7eb1da6 100644 --- a/discovery/src/main/java/org/openedx/discovery/presentation/detail/CourseDetailsFragment.kt +++ b/discovery/src/main/java/org/openedx/discovery/presentation/detail/CourseDetailsFragment.kt @@ -164,7 +164,7 @@ class CourseDetailsFragment : Fragment() { requireActivity().supportFragmentManager, currentState.course.courseId, currentState.course.name, - "", + enrollmentMode = "" ) } diff --git a/discovery/src/main/java/org/openedx/discovery/presentation/info/CourseInfoViewModel.kt b/discovery/src/main/java/org/openedx/discovery/presentation/info/CourseInfoViewModel.kt index 636cb9275..6d41ac4b1 100644 --- a/discovery/src/main/java/org/openedx/discovery/presentation/info/CourseInfoViewModel.kt +++ b/discovery/src/main/java/org/openedx/discovery/presentation/info/CourseInfoViewModel.kt @@ -122,7 +122,7 @@ class CourseInfoViewModel( fm = fragmentManager, courseId = courseId, courseTitle = "", - enrollmentMode = "", + enrollmentMode = "" ) } } diff --git a/discussion/src/main/java/org/openedx/discussion/data/api/DiscussionApi.kt b/discussion/src/main/java/org/openedx/discussion/data/api/DiscussionApi.kt index ebc911425..75a780d72 100644 --- a/discussion/src/main/java/org/openedx/discussion/data/api/DiscussionApi.kt +++ b/discussion/src/main/java/org/openedx/discussion/data/api/DiscussionApi.kt @@ -5,6 +5,7 @@ import org.openedx.discussion.data.model.request.* import org.openedx.discussion.data.model.response.CommentResult import org.openedx.discussion.data.model.response.CommentsResponse import org.openedx.discussion.data.model.response.ThreadsResponse +import org.openedx.discussion.data.model.response.ThreadsResponse.Thread import org.openedx.discussion.data.model.response.TopicsResponse import retrofit2.http.* @@ -26,6 +27,14 @@ interface DiscussionApi { @Query("requested_fields") requestedFields: List = listOf("profile_image") ): ThreadsResponse + @GET("/api/discussion/v1/threads/{thread_id}") + suspend fun getCourseThread( + @Path("thread_id") threadId: String, + @Query("course_id") courseId: String, + @Query("topic_id") topicId: String, + @Query("requested_fields") requestedFields: List = listOf("profile_image") + ): Thread + @GET("/api/discussion/v1/threads/") suspend fun searchThreads( @Query("course_id") courseId: String, @@ -41,6 +50,12 @@ interface DiscussionApi { @Query("requested_fields") requestedFields: List = listOf("profile_image") ): CommentsResponse + @GET("/api/discussion/v1/comments/{comment_id}") + suspend fun getThreadComment( + @Path("comment_id") commentId: String, + @Query("requested_fields") requestedFields: List = listOf("profile_image") + ): CommentsResponse + @GET("/api/discussion/v1/comments/") suspend fun getThreadQuestionComments( @Query("thread_id") threadId: 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 4ca6cde8d..3ee4f74a5 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 @@ -58,6 +58,14 @@ class DiscussionRepository( return api.getCourseThreads(courseId, following, topicId, orderBy, view, page).mapToDomain() } + suspend fun getCourseThread( + threadId: String, + courseId: String, + topicId: String + ): org.openedx.discussion.domain.model.Thread { + return api.getCourseThread(threadId, courseId, topicId).mapToDomain() + } + suspend fun searchThread( courseId: String, query: String, @@ -73,6 +81,12 @@ class DiscussionRepository( return api.getThreadComments(threadId, page).mapToDomain() } + suspend fun getThreadComment( + commentId: String + ): CommentsData { + return api.getThreadComment(commentId).mapToDomain() + } + suspend fun getThreadQuestionComments( threadId: String, endorsed: Boolean, @@ -142,4 +156,4 @@ class DiscussionRepository( return api.markBlocksCompletion(blocksCompletionBody) } -} \ No newline at end of file +} diff --git a/discussion/src/main/java/org/openedx/discussion/domain/interactor/DiscussionInteractor.kt b/discussion/src/main/java/org/openedx/discussion/domain/interactor/DiscussionInteractor.kt index 7225cc443..561a75006 100644 --- a/discussion/src/main/java/org/openedx/discussion/domain/interactor/DiscussionInteractor.kt +++ b/discussion/src/main/java/org/openedx/discussion/domain/interactor/DiscussionInteractor.kt @@ -1,6 +1,7 @@ package org.openedx.discussion.domain.interactor import org.openedx.discussion.data.repository.DiscussionRepository +import org.openedx.discussion.domain.model.CommentsData class DiscussionInteractor( private val repository: DiscussionRepository @@ -31,12 +32,18 @@ class DiscussionInteractor( ) = repository.getCourseThreads(courseId, null, topicId, orderBy, view, page) + suspend fun getThread(threadId: String, courseId: String, topicId: String) = + repository.getCourseThread(threadId, courseId, topicId) + suspend fun searchThread(courseId: String, query: String, page: Int) = repository.searchThread(courseId, query, page) suspend fun getThreadComments(threadId: String, page: Int) = repository.getThreadComments(threadId, page) + suspend fun getThreadComment(commentId: String): CommentsData = + repository.getThreadComment(commentId) + suspend fun getThreadQuestionComments(threadId: String, endorsed: Boolean, page: Int) = repository.getThreadQuestionComments(threadId, endorsed, page) @@ -87,5 +94,6 @@ class DiscussionInteractor( follow: Boolean ) = repository.createThread(topicId, courseId, type, title, rawBody, follow) - suspend fun markBlocksCompletion(courseId: String, blocksId: List) = repository.markBlocksCompletion(courseId, blocksId) -} \ No newline at end of file + suspend fun markBlocksCompletion(courseId: String, blocksId: List) = + repository.markBlocksCompletion(courseId, blocksId) +} diff --git a/whatsnew/src/main/java/org/openedx/whatsnew/WhatsNewRouter.kt b/whatsnew/src/main/java/org/openedx/whatsnew/WhatsNewRouter.kt index a8d1cd463..82d02f000 100644 --- a/whatsnew/src/main/java/org/openedx/whatsnew/WhatsNewRouter.kt +++ b/whatsnew/src/main/java/org/openedx/whatsnew/WhatsNewRouter.kt @@ -3,5 +3,10 @@ package org.openedx.whatsnew import androidx.fragment.app.FragmentManager interface WhatsNewRouter { - fun navigateToMain(fm: FragmentManager, courseId: String? = null, infoType: String? = null) + fun navigateToMain( + fm: FragmentManager, + courseId: String?, + infoType: String?, + openTab: String + ) } 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 51f0f9646..534a54f13 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 @@ -41,7 +41,8 @@ class WhatsNewViewModel( router.navigateToMain( fm, courseId, - infoType + infoType, + "" ) }