Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions app/src/main/java/org/openedx/app/di/ScreenModule.kt
Original file line number Diff line number Diff line change
Expand Up @@ -293,6 +293,7 @@ val screenModule = module {
get(),
get(),
get(),
get(),
)
}
viewModel { (courseId: String, unitId: String) ->
Expand Down
4 changes: 4 additions & 0 deletions core/src/main/java/org/openedx/core/data/api/CourseApi.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
}
Original file line number Diff line number Diff line change
@@ -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
)
}
}
30 changes: 30 additions & 0 deletions core/src/main/java/org/openedx/core/data/model/SequenceModel.kt
Original file line number Diff line number Diff line change
@@ -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(),
)
}
}
13 changes: 13 additions & 0 deletions core/src/main/java/org/openedx/core/domain/model/GatedContent.kt
Original file line number Diff line number Diff line change
@@ -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
14 changes: 14 additions & 0 deletions core/src/main/java/org/openedx/core/domain/model/Subsection.kt
Original file line number Diff line number Diff line change
@@ -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
Original file line number Diff line number Diff line change
Expand Up @@ -139,4 +139,6 @@ class CourseRepository(
downloadDao.removeOfflineXBlockProgress(listOf(blockId))
}
}

suspend fun getSequence(sequenceId: String) = api.getSequence(sequenceId).mapToDomain()
}
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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 }) {
Expand Down Expand Up @@ -176,6 +188,7 @@ private fun CourseSectionScreen(
uiMessage: UIMessage?,
onBackClick: () -> Unit,
onItemClick: (Block) -> Unit,
onGoToPrerequisiteClick: (String) -> Unit
) {
val scaffoldState = rememberScaffoldState()
val title = when (uiState) {
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -361,6 +409,7 @@ private fun CourseSectionScreenPreview() {
uiMessage = null,
onBackClick = {},
onItemClick = {},
onGoToPrerequisiteClick = {}
)
}
}
Expand All @@ -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 = {}
)
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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()
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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() {
Expand Down Expand Up @@ -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
}
}
Comment on lines +59 to +69
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@0x29a, thanks for the contribution; it's a great improvement.

A few architectural concerns:

To address both issues, we must rely on the /api/mobile/{api_version}/course_info/blocks/ endpoint that we use to work with course structure and related data. If that endpoint doesn't provide us with that information, I would consider starting by adding that data to the endpoint.

@e0d @marcotuts wdyt?

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thank you for the review, @volodymyr-chekyrta!

We've tried to add the locked content information to that endpoint, but it proved to be really hard, so we had to resort to this "online-only" half-measure, which solves the problem for us. Once locked content data is available in the /api/mobile/{api_version}/course_info/blocks/ endpoint, it should be very trivial to update the implementation of this feature to use it.

val courseStructure = when (mode) {
CourseViewMode.FULL -> interactor.getCourseStructure(courseId)
CourseViewMode.VIDEOS -> interactor.getCourseStructureForVideos(courseId)
Expand Down Expand Up @@ -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)
}
)
}
}
}
2 changes: 2 additions & 0 deletions course/src/main/res/values/strings.xml
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,8 @@
<string name="course_resume">Resume</string>
<string name="course_to_proceed">To proceed with \"%s\" press \"Next section\".</string>
<string name="course_gated_content_label">Some content in this part of the course is locked for upgraded users only.</string>
<string name="course_gated_subsection">Content locked. You must complete the prerequisite: \“%s\” to access this content.</string>
<string name="course_go_to_prerequisite_section">Go To Prerequisite Section</string>
<string name="course_change_quality_when_downloading">You cannot change the download video quality when all videos are downloading</string>
<string name="course_dates_shifted_message">Dates Shifted</string>

Expand Down
Loading