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 6b7692f99..abb238485 100644 --- a/app/src/main/java/org/openedx/app/di/ScreenModule.kt +++ b/app/src/main/java/org/openedx/app/di/ScreenModule.kt @@ -293,6 +293,7 @@ val screenModule = module { get(), get(), get(), + get(), ) } viewModel { (courseId: String, unitId: String) -> diff --git a/core/src/main/java/org/openedx/core/data/api/CourseApi.kt b/core/src/main/java/org/openedx/core/data/api/CourseApi.kt index 8b5f0913a..3dd83b931 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 @@ -12,6 +12,7 @@ import org.openedx.core.data.model.CourseStructureModel import org.openedx.core.data.model.EnrollmentStatus import org.openedx.core.data.model.HandoutsModel import org.openedx.core.data.model.ResetCourseDates +import org.openedx.core.data.model.SequenceModel import retrofit2.http.Body import retrofit2.http.GET import retrofit2.http.Header @@ -100,4 +101,7 @@ interface CourseApi { suspend fun getEnrollmentDetails( @Path("course_id") courseId: String, ): CourseEnrollmentDetails + + @GET("api/courseware/sequence/{sequence_id}/") + suspend fun getSequence(@Path("sequence_id") sequenceId: String): SequenceModel } diff --git a/core/src/main/java/org/openedx/core/data/model/GatedContentModel.kt b/core/src/main/java/org/openedx/core/data/model/GatedContentModel.kt new file mode 100644 index 000000000..912f5145d --- /dev/null +++ b/core/src/main/java/org/openedx/core/data/model/GatedContentModel.kt @@ -0,0 +1,27 @@ +package org.openedx.core.data.model + +import com.google.gson.annotations.SerializedName +import org.openedx.core.domain.model.GatedContent + +data class GatedContentModel( + @SerializedName("prereq_id") + val prereqId: String?, + @SerializedName("prereq_url") + val prereqUrl: String?, + @SerializedName("prereq_section_name") + val prereqSectionName: String?, + @SerializedName("gated") + val gated: Boolean, + @SerializedName("gated_section_name") + val gatedSectionName: String?, +) { + fun mapToDomain(): GatedContent { + return GatedContent( + prereqId = prereqId, + prereqUrl = prereqUrl, + prereqSubsectionName = prereqSectionName, + gated = gated, + gatedSubsectionName = gatedSectionName + ) + } +} diff --git a/core/src/main/java/org/openedx/core/data/model/SequenceModel.kt b/core/src/main/java/org/openedx/core/data/model/SequenceModel.kt new file mode 100644 index 000000000..c88af3abc --- /dev/null +++ b/core/src/main/java/org/openedx/core/data/model/SequenceModel.kt @@ -0,0 +1,30 @@ +package org.openedx.core.data.model + +import com.google.gson.annotations.SerializedName +import org.openedx.core.domain.model.Subsection + +data class SequenceModel( + @SerializedName("element_id") + val elementId: String, + @SerializedName("item_id") + val itemId: String, + @SerializedName("banner_text") + val bannerText: String?, + @SerializedName("gated_content") + val gatedContentModel: GatedContentModel, + @SerializedName("sequence_name") + val sequenceName: String, + @SerializedName("display_name") + val displayName: String, +) { + fun mapToDomain(): Subsection { + return Subsection( + elementId = elementId, + itemId = itemId, + bannerText = bannerText, + subsectionName = sequenceName, + displayName = displayName, + gatedContent = gatedContentModel.mapToDomain(), + ) + } +} diff --git a/core/src/main/java/org/openedx/core/domain/model/GatedContent.kt b/core/src/main/java/org/openedx/core/domain/model/GatedContent.kt new file mode 100644 index 000000000..e0e2d758e --- /dev/null +++ b/core/src/main/java/org/openedx/core/domain/model/GatedContent.kt @@ -0,0 +1,13 @@ +package org.openedx.core.domain.model + +import android.os.Parcelable +import kotlinx.parcelize.Parcelize + +@Parcelize +data class GatedContent( + val prereqId: String?, + val prereqUrl: String?, + val prereqSubsectionName: String?, + val gated: Boolean, + val gatedSubsectionName: String? +) : Parcelable diff --git a/core/src/main/java/org/openedx/core/domain/model/Subsection.kt b/core/src/main/java/org/openedx/core/domain/model/Subsection.kt new file mode 100644 index 000000000..1371234b6 --- /dev/null +++ b/core/src/main/java/org/openedx/core/domain/model/Subsection.kt @@ -0,0 +1,14 @@ +package org.openedx.core.domain.model + +import android.os.Parcelable +import kotlinx.parcelize.Parcelize + +@Parcelize +data class Subsection( + val elementId: String, + val itemId: String, + val bannerText: String?, + val gatedContent: GatedContent, + val subsectionName: String, + val displayName: String +) : Parcelable 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 d9034e4ef..0609ad583 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 @@ -139,4 +139,6 @@ class CourseRepository( downloadDao.removeOfflineXBlockProgress(listOf(blockId)) } } + + suspend fun getSequence(sequenceId: String) = api.getSequence(sequenceId).mapToDomain() } 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 fdbcdd204..f158079d2 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 @@ -82,6 +82,8 @@ class CourseInteractor( suspend fun removeDownloadModel(id: String) = repository.removeDownloadModel(id) + suspend fun getSubsection(subsectionId: String) = repository.getSequence(subsectionId) + fun getDownloadModels() = repository.getDownloadModels() suspend fun getAllDownloadModels() = repository.getAllDownloadModels() 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 0dbe660e5..a5b01a097 100644 --- a/course/src/main/java/org/openedx/course/presentation/CourseAnalytics.kt +++ b/course/src/main/java/org/openedx/course/presentation/CourseAnalytics.kt @@ -78,6 +78,10 @@ enum class CourseAnalyticsEvent(val eventName: String, val biValue: String) { "Course:Unit Detail", "edx.bi.app.course.unit_detail" ), + PREREQUISITE( + "Course:Prerequisite", + "edx.bi.app.course.prerequisite" + ), VIEW_CERTIFICATE( "Course:View Certificate Clicked", "edx.bi.app.course.view_certificate.clicked" 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 75a100ab8..7b3b339ab 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 @@ -4,6 +4,7 @@ import android.content.res.Configuration import android.os.Bundle import android.view.LayoutInflater import android.view.ViewGroup +import androidx.compose.foundation.Image import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box @@ -17,6 +18,7 @@ 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.lazy.LazyColumn @@ -61,6 +63,7 @@ import org.openedx.core.domain.model.BlockCounts import org.openedx.core.presentation.course.CourseViewMode import org.openedx.core.ui.BackBtn import org.openedx.core.ui.HandleUIMessage +import org.openedx.core.ui.OpenEdXButton import org.openedx.core.ui.displayCutoutForLandscape import org.openedx.core.ui.statusBarsInset import org.openedx.core.ui.theme.OpenEdXTheme @@ -124,6 +127,15 @@ class CourseSectionFragment : Fragment() { ) } }, + onGoToPrerequisiteClick = { subSectionId -> + viewModel.goToPrerequisiteSectionClickedEvent(subSectionId) + router.navigateToCourseSubsections( + fm = requireActivity().supportFragmentManager, + courseId = viewModel.courseId, + subSectionId = subSectionId, + mode = CourseViewMode.FULL + ) + } ) LaunchedEffect(rememberSaveable { true }) { @@ -176,6 +188,7 @@ private fun CourseSectionScreen( uiMessage: UIMessage?, onBackClick: () -> Unit, onItemClick: (Block) -> Unit, + onGoToPrerequisiteClick: (String) -> Unit ) { val scaffoldState = rememberScaffoldState() val title = when (uiState) { @@ -256,6 +269,41 @@ private fun CourseSectionScreen( } } + is CourseSectionUIState.Gated -> { + Column( + modifier = Modifier.fillMaxSize(), + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally + ) { + Image( + painter = painterResource(id = R.drawable.ic_course_gated), + contentDescription = "gated", + modifier = Modifier.size(48.dp) + ) + Spacer(modifier = Modifier.height(16.dp)) + Text( + modifier = Modifier + .padding(horizontal = 16.dp) + .fillMaxWidth(), + text = stringResource( + id = R.string.course_gated_subsection, + uiState.prereqSubsectionName ?: "" + ), + textAlign = TextAlign.Center, + style = MaterialTheme.appTypography.titleMedium, + color = MaterialTheme.appColors.textPrimary, + ) + Spacer(modifier = Modifier.height(16.dp)) + OpenEdXButton( + text = stringResource(id = R.string.course_go_to_prerequisite_section), + onClick = { + onGoToPrerequisiteClick(uiState.prereqId ?: "") + }, + modifier = Modifier.padding(top = 16.dp) + ) + } + } + is CourseSectionUIState.Blocks -> { Column(Modifier.fillMaxSize()) { LazyColumn( @@ -361,6 +409,7 @@ private fun CourseSectionScreenPreview() { uiMessage = null, onBackClick = {}, onItemClick = {}, + onGoToPrerequisiteClick = {} ) } } @@ -385,6 +434,29 @@ private fun CourseSectionScreenTabletPreview() { uiMessage = null, onBackClick = {}, onItemClick = {}, + onGoToPrerequisiteClick = {} + ) + } +} + +@Preview(uiMode = Configuration.UI_MODE_NIGHT_NO) +@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES) +@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 CourseSectionScreenGatedPreview() { + OpenEdXTheme { + CourseSectionScreen( + windowSize = WindowSize(WindowType.Medium, WindowType.Medium), + uiState = CourseSectionUIState.Gated( + "Gated Subsection", + "Prerequisite Subsection", + "Prerequisite Id" + ), + uiMessage = null, + onBackClick = {}, + onItemClick = {}, + onGoToPrerequisiteClick = {} ) } } diff --git a/course/src/main/java/org/openedx/course/presentation/section/CourseSectionUIState.kt b/course/src/main/java/org/openedx/course/presentation/section/CourseSectionUIState.kt index 166da30c2..9507247a3 100644 --- a/course/src/main/java/org/openedx/course/presentation/section/CourseSectionUIState.kt +++ b/course/src/main/java/org/openedx/course/presentation/section/CourseSectionUIState.kt @@ -9,4 +9,9 @@ sealed class CourseSectionUIState { val courseName: String ) : CourseSectionUIState() data object Loading : CourseSectionUIState() + data class Gated( + val gatedSubsectionName: String?, + val prereqSubsectionName: String?, + val prereqId: String?, + ) : CourseSectionUIState() } 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 2ebe2c9b3..ebe8dc078 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 @@ -9,6 +9,7 @@ import org.openedx.core.BlockType import org.openedx.core.R import org.openedx.core.domain.model.Block import org.openedx.core.presentation.course.CourseViewMode +import org.openedx.core.system.connection.NetworkConnection import org.openedx.core.system.notifier.CourseNotifier import org.openedx.core.system.notifier.CourseSectionChanged import org.openedx.course.domain.interactor.CourseInteractor @@ -25,6 +26,7 @@ class CourseSectionViewModel( val courseId: String, private val interactor: CourseInteractor, private val resourceManager: ResourceManager, + private val networkConnection: NetworkConnection, private val notifier: CourseNotifier, private val analytics: CourseAnalytics, ) : BaseViewModel() { @@ -54,6 +56,17 @@ class CourseSectionViewModel( _uiState.value = CourseSectionUIState.Loading viewModelScope.launch { try { + if (networkConnection.isOnline()) { + val sectionData = interactor.getSubsection(blockId) + if (sectionData.gatedContent.gated) { + _uiState.value = CourseSectionUIState.Gated( + prereqId = sectionData.gatedContent.prereqId, + prereqSubsectionName = sectionData.gatedContent.prereqSubsectionName, + gatedSubsectionName = sectionData.gatedContent.gatedSubsectionName, + ) + return@launch + } + } val courseStructure = when (mode) { CourseViewMode.FULL -> interactor.getCourseStructure(courseId) CourseViewMode.VIDEOS -> interactor.getCourseStructureForVideos(courseId) @@ -118,4 +131,19 @@ class CourseSectionViewModel( ) } } + + fun goToPrerequisiteSectionClickedEvent(subSectionId: String) { + val currentState = uiState.value + if (currentState is CourseSectionUIState.Gated) { + analytics.logEvent( + event = CourseAnalyticsEvent.PREREQUISITE.eventName, + params = buildMap { + put(CourseAnalyticsKey.NAME.key, CourseAnalyticsEvent.PREREQUISITE.biValue) + put(CourseAnalyticsKey.COURSE_ID.key, courseId) + put(CourseAnalyticsKey.BLOCK_ID.key, subSectionId) + put(CourseAnalyticsKey.CATEGORY.key, CourseAnalyticsKey.NAVIGATION.key) + } + ) + } + } } diff --git a/course/src/main/res/values/strings.xml b/course/src/main/res/values/strings.xml index 59c536295..5779f7eaa 100644 --- a/course/src/main/res/values/strings.xml +++ b/course/src/main/res/values/strings.xml @@ -34,6 +34,8 @@ Resume To proceed with \"%s\" press \"Next section\". Some content in this part of the course is locked for upgraded users only. + Content locked. You must complete the prerequisite: \“%s\” to access this content. + Go To Prerequisite Section You cannot change the download video quality when all videos are downloading Dates Shifted 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 02eda9622..4bdf693f7 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 @@ -31,6 +31,8 @@ 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.GatedContent +import org.openedx.core.domain.model.Subsection import org.openedx.core.module.DownloadWorkerController import org.openedx.core.module.db.DownloadDao import org.openedx.core.module.db.DownloadModel @@ -161,6 +163,36 @@ class CourseSectionViewModelTest { progress = null ) + private val subsection = Subsection( + elementId = "id", + itemId = "id", + bannerText = "bannerText", + gatedContent = GatedContent( + prereqId = null, + prereqUrl = null, + prereqSubsectionName = null, + gated = false, + gatedSubsectionName = null + ), + subsectionName = "subsectionName", + displayName = "displayName" + ) + + private val gatedSubsection = Subsection( + elementId = "id", + itemId = "id", + bannerText = "bannerText", + gatedContent = GatedContent( + prereqId = "prereqId", + prereqUrl = "prereqUrl", + prereqSubsectionName = "prereqSubsectionName", + gated = true, + gatedSubsectionName = "gatedSubsectionName" + ), + subsectionName = "subsectionName", + displayName = "displayName" + ) + private val downloadModel = DownloadModel( "id", "title", @@ -181,6 +213,8 @@ class CourseSectionViewModelTest { every { resourceManager.getString(org.openedx.course.R.string.course_can_download_only_with_wifi) } returns cantDownload + every { networkConnection.isOnline() } returns true + coEvery { interactor.getSubsection("id") } returns subsection } @After @@ -195,17 +229,20 @@ class CourseSectionViewModelTest { "", interactor, resourceManager, + networkConnection, notifier, analytics, ) + coEvery { interactor.getSubsection("") } throws UnknownHostException() coEvery { interactor.getCourseStructure(any()) } throws UnknownHostException() coEvery { interactor.getCourseStructureForVideos(any()) } throws UnknownHostException() viewModel.getBlocks("", CourseViewMode.FULL) advanceUntilIdle() - coVerify(exactly = 1) { interactor.getCourseStructure(any()) } + coVerify(exactly = 1) { interactor.getSubsection("") } + coVerify(exactly = 0) { interactor.getCourseStructure(any()) } coVerify(exactly = 0) { interactor.getCourseStructureForVideos(any()) } val message = viewModel.uiMessage.value as? UIMessage.SnackBarMessage @@ -220,17 +257,20 @@ class CourseSectionViewModelTest { "", interactor, resourceManager, + networkConnection, notifier, analytics, ) + coEvery { interactor.getSubsection("id2") } throws Exception() coEvery { interactor.getCourseStructure(any()) } throws Exception() coEvery { interactor.getCourseStructureForVideos(any()) } throws Exception() viewModel.getBlocks("id2", CourseViewMode.FULL) advanceUntilIdle() - coVerify(exactly = 1) { interactor.getCourseStructure(any()) } + coVerify(exactly = 1) { interactor.getSubsection("id2") } + coVerify(exactly = 0) { interactor.getCourseStructure(any()) } coVerify(exactly = 0) { interactor.getCourseStructureForVideos(any()) } val message = viewModel.uiMessage.value as? UIMessage.SnackBarMessage @@ -247,6 +287,7 @@ class CourseSectionViewModelTest { "", interactor, resourceManager, + networkConnection, notifier, analytics, ) @@ -276,6 +317,7 @@ class CourseSectionViewModelTest { "", interactor, resourceManager, + networkConnection, notifier, analytics, ) @@ -298,6 +340,7 @@ class CourseSectionViewModelTest { "", interactor, resourceManager, + networkConnection, notifier, analytics, ) @@ -323,6 +366,7 @@ class CourseSectionViewModelTest { "", interactor, resourceManager, + networkConnection, notifier, analytics, ) @@ -341,4 +385,24 @@ class CourseSectionViewModelTest { assert(viewModel.uiState.value is CourseSectionUIState.Blocks) } + + @Test + fun `subsection is gated`() = runTest { + every { downloadDao.getAllDataFlow() } returns flow { emit(emptyList()) } + val viewModel = CourseSectionViewModel( + "", + interactor, + resourceManager, + networkConnection, + notifier, + analytics, + ) + + coEvery { interactor.getSubsection("id") } returns gatedSubsection + + viewModel.getBlocks("id", CourseViewMode.FULL) + advanceUntilIdle() + + assert(viewModel.uiState.value is CourseSectionUIState.Gated) + } }