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)
+ }
}