From e3c01d89a1699fe05f0e461394c4aeb49a95a6ff Mon Sep 17 00:00:00 2001 From: Yogesh Bhagat Date: Wed, 29 Oct 2025 18:51:09 +0530 Subject: [PATCH 1/6] implement handler for locked content due to prerequisite --- .../org/openedx/core/data/api/CourseApi.kt | 2 +- .../java/org/openedx/core/data/model/Block.kt | 3 + .../openedx/core/data/model/GatedContent.kt | 35 +++++ .../openedx/core/data/model/room/BlockDb.kt | 25 +++ .../org/openedx/core/domain/model/Block.kt | 3 +- .../openedx/core/domain/model/GatedContent.kt | 14 ++ .../unit/PrerequisiteLockedFragment.kt | 148 ++++++++++++++++++ .../container/CourseUnitContainerAdapter.kt | 22 +++ .../container/CourseUnitContainerViewModel.kt | 26 ++- course/src/main/res/values/strings.xml | 3 + 10 files changed, 277 insertions(+), 4 deletions(-) create mode 100644 core/src/main/java/org/openedx/core/data/model/GatedContent.kt create mode 100644 core/src/main/java/org/openedx/core/domain/model/GatedContent.kt create mode 100644 course/src/main/java/org/openedx/course/presentation/unit/PrerequisiteLockedFragment.kt 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..a9fece828 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 @@ -34,7 +34,7 @@ interface CourseApi { @GET( "/api/mobile/{api_version}/course_info/blocks/?" + "depth=all&" + - "requested_fields=contains_gated_content,show_gated_sections,special_exam_info,graded,format," + + "requested_fields=contains_gated_content,show_gated_sections,gated_content,special_exam_info,graded,format," + "student_view_multi_device,due,completion&" + "student_view_data=video,discussion&" + "block_counts=video&" + diff --git a/core/src/main/java/org/openedx/core/data/model/Block.kt b/core/src/main/java/org/openedx/core/data/model/Block.kt index 8ac8a8378..00411e5ab 100644 --- a/core/src/main/java/org/openedx/core/data/model/Block.kt +++ b/core/src/main/java/org/openedx/core/data/model/Block.kt @@ -38,6 +38,8 @@ data class Block( val completion: Double?, @SerializedName("contains_gated_content") val containsGatedContent: Boolean?, + @SerializedName("gated_content") + val gatedContent: GatedContent?, @SerializedName("assignment_progress") val assignmentProgress: AssignmentProgress?, @SerializedName("due") @@ -63,6 +65,7 @@ data class Block( studentViewData = studentViewData?.mapToDomain(), studentViewMultiDevice = studentViewMultiDevice ?: false, blockCounts = blockCounts?.mapToDomain()!!, + gatedContent = gatedContent?.mapToDomain(), completion = completion ?: 0.0, containsGatedContent = containsGatedContent ?: false, assignmentProgress = assignmentProgress?.mapToDomain(), diff --git a/core/src/main/java/org/openedx/core/data/model/GatedContent.kt b/core/src/main/java/org/openedx/core/data/model/GatedContent.kt new file mode 100644 index 000000000..9d5dee2ea --- /dev/null +++ b/core/src/main/java/org/openedx/core/data/model/GatedContent.kt @@ -0,0 +1,35 @@ +package org.openedx.core.data.model + +import com.google.gson.annotations.SerializedName +import org.openedx.core.data.model.room.GatedContentDb +import org.openedx.core.domain.model.GatedContent as DomainGatedContent + +data class GatedContent( + @SerializedName("prereq_id") + val prereqId: String?, + @SerializedName("prereq_section_name") + val prereqSectionName: String?, + @SerializedName("gated") + val gated: Boolean?, + @SerializedName("gated_section_name") + val gatedSectionName: String?, + @SerializedName("prereq_url") + val prereqUrl: String? +) { + fun mapToDomain() = DomainGatedContent( + prereqId = prereqId.orEmpty(), + prereqSectionName = prereqSectionName.orEmpty(), + gated = gated ?: false, + gatedSectionName = gatedSectionName.orEmpty(), + prereqUrl = prereqUrl.orEmpty() + ) + + fun mapToRoomEntity() = GatedContentDb( + prereqId = prereqId, + prereqSectionName = prereqSectionName, + gated = gated, + gatedSectionName = gatedSectionName, + prereqUrl = prereqUrl + ) +} + diff --git a/core/src/main/java/org/openedx/core/data/model/room/BlockDb.kt b/core/src/main/java/org/openedx/core/data/model/room/BlockDb.kt index a60d9e68c..3d9fb7af4 100644 --- a/core/src/main/java/org/openedx/core/data/model/room/BlockDb.kt +++ b/core/src/main/java/org/openedx/core/data/model/room/BlockDb.kt @@ -46,6 +46,8 @@ data class BlockDb( @ColumnInfo("contains_gated_content") val containsGatedContent: Boolean, @Embedded + val gatedContent: GatedContentDb?, + @Embedded val assignmentProgress: AssignmentProgressDb?, @ColumnInfo("due") val due: String?, @@ -78,6 +80,7 @@ data class BlockDb( blockCounts = blockCounts.mapToDomain(), descendants = descendants, descendantsType = descendantsType, + gatedContent = gatedContent?.mapToDomain(), completion = completion, containsGatedContent = containsGatedContent, assignmentProgress = assignmentProgress?.mapToDomain(), @@ -104,6 +107,7 @@ data class BlockDb( graded = graded ?: false, studentViewData = StudentViewDataDb.createFrom(studentViewData), studentViewMultiDevice = studentViewMultiDevice ?: false, + gatedContent = gatedContent?.mapToRoomEntity(), blockCounts = BlockCountsDb.createFrom(blockCounts), completion = completion ?: 0.0, containsGatedContent = containsGatedContent ?: false, @@ -254,3 +258,24 @@ data class OfflineDownloadDb( ) } } + +data class GatedContentDb( + @ColumnInfo("prereq_id") + val prereqId: String?, + @ColumnInfo("prereq_section_name") + val prereqSectionName: String?, + @ColumnInfo("gated") + val gated: Boolean?, + @ColumnInfo("gated_section_name") + val gatedSectionName: String?, + @ColumnInfo("prereq_url") + val prereqUrl: String? +) { + fun mapToDomain() = org.openedx.core.domain.model.GatedContent( + prereqId = prereqId.orEmpty(), + prereqSectionName = prereqSectionName.orEmpty(), + gated = gated ?: false, + gatedSectionName = gatedSectionName.orEmpty(), + prereqUrl = prereqUrl.orEmpty() + ) +} diff --git a/core/src/main/java/org/openedx/core/domain/model/Block.kt b/core/src/main/java/org/openedx/core/domain/model/Block.kt index ba7b91a41..64795a17c 100644 --- a/core/src/main/java/org/openedx/core/domain/model/Block.kt +++ b/core/src/main/java/org/openedx/core/domain/model/Block.kt @@ -29,6 +29,7 @@ data class Block( val descendantsType: BlockType, val completion: Double, val containsGatedContent: Boolean = false, + val gatedContent: GatedContent? = null, val downloadModel: DownloadModel? = null, val assignmentProgress: AssignmentProgress?, val due: Date?, @@ -58,7 +59,7 @@ data class Block( fun isDownloaded() = downloadModel?.downloadedState == DownloadedState.DOWNLOADED - fun isGated() = containsGatedContent + fun isGated() = containsGatedContent || gatedContent?.gated == true fun isCompleted() = completion == 1.0 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..0e7a3288b --- /dev/null +++ b/core/src/main/java/org/openedx/core/domain/model/GatedContent.kt @@ -0,0 +1,14 @@ +package org.openedx.core.domain.model + +import android.os.Parcelable +import kotlinx.parcelize.Parcelize + +@Parcelize +data class GatedContent( + val prereqId: String, + val prereqSectionName: String, + val gated: Boolean, + val gatedSectionName: String, + val prereqUrl: String +) : Parcelable + diff --git a/course/src/main/java/org/openedx/course/presentation/unit/PrerequisiteLockedFragment.kt b/course/src/main/java/org/openedx/course/presentation/unit/PrerequisiteLockedFragment.kt new file mode 100644 index 000000000..7a6dfe1f5 --- /dev/null +++ b/course/src/main/java/org/openedx/course/presentation/unit/PrerequisiteLockedFragment.kt @@ -0,0 +1,148 @@ +package org.openedx.course.presentation.unit + +import android.content.res.Configuration.UI_MODE_NIGHT_NO +import android.content.res.Configuration.UI_MODE_NIGHT_YES +import android.os.Bundle +import android.view.LayoutInflater +import android.view.ViewGroup +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.* +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.ComposeView +import androidx.compose.ui.platform.ViewCompositionStrategy +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.fragment.app.Fragment +import org.openedx.core.ui.OpenEdXButton +import org.openedx.core.ui.theme.OpenEdXTheme +import org.openedx.core.ui.theme.appColors +import org.openedx.core.ui.theme.appTypography +import org.openedx.foundation.extension.parcelable +import org.openedx.course.R as courseR + +class PrerequisiteLockedFragment : Fragment() { + + interface PrerequisiteNavigationListener { + fun navigateToPrerequisite(prereqId: String) + } + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ) = ComposeView(requireContext()).apply { + setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed) + setContent { + OpenEdXTheme { + val prereqData = arguments?.parcelable(ARG_PREREQUISITE_DATA) + + PrerequisiteLockedScreen( + prereqSectionName = prereqData?.prereqSectionName ?: "", + onNavigateToPrerequisite = { + } + ) + } + } + } + + companion object { + private const val ARG_PREREQUISITE_DATA = "prerequisite_data" + + fun newInstance( + prereqId: String, + prereqSectionName: String, + ): PrerequisiteLockedFragment { + val fragment = PrerequisiteLockedFragment() + fragment.arguments = Bundle().apply { + putParcelable( + ARG_PREREQUISITE_DATA, + PrerequisiteData(prereqId, prereqSectionName) + ) + } + return fragment + } + } +} + +@kotlinx.parcelize.Parcelize +data class PrerequisiteData( + val prereqId: String, + val prereqSectionName: String, +) : android.os.Parcelable + +@Composable +private fun PrerequisiteLockedScreen( + prereqSectionName: String, + onNavigateToPrerequisite: () -> Unit +) { + Surface( + modifier = Modifier.fillMaxSize(), + color = MaterialTheme.appColors.background + ) { + Column( + modifier = Modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()) + .padding(horizontal = 24.dp, vertical = 32.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center + ) { + Icon( + painter = painterResource(id = courseR.drawable.ic_lock), + contentDescription = null, + tint = MaterialTheme.appColors.textPrimary, + modifier = Modifier.size(100.dp) + ) + + Spacer(modifier = Modifier.height(24.dp)) + + Text( + text = stringResource(id = courseR.string.course_content_locked), + style = MaterialTheme.appTypography.titleLarge, + color = MaterialTheme.appColors.textPrimary, + textAlign = TextAlign.Center + ) + + Spacer(modifier = Modifier.height(16.dp)) + + Text( + text = stringResource( + id = courseR.string.course_must_complete_prerequisite, + prereqSectionName + ), + style = MaterialTheme.appTypography.bodyMedium, + color = MaterialTheme.appColors.textPrimary, + textAlign = TextAlign.Center + ) + + Spacer(modifier = Modifier.height(32.dp)) + + OpenEdXButton( + text = stringResource(id = courseR.string.course_go_to_prerequisite_section), + onClick = onNavigateToPrerequisite, + modifier = Modifier + .fillMaxWidth() + .height(48.dp) + ) + } + } +} + +@Preview(uiMode = UI_MODE_NIGHT_NO) +@Preview(uiMode = UI_MODE_NIGHT_YES) +@Composable +private fun PrerequisiteLockedScreenPreview() { + OpenEdXTheme { + PrerequisiteLockedScreen( + prereqSectionName = "Introduction to Programming", + onNavigateToPrerequisite = {} + ) + } +} diff --git a/course/src/main/java/org/openedx/course/presentation/unit/container/CourseUnitContainerAdapter.kt b/course/src/main/java/org/openedx/course/presentation/unit/container/CourseUnitContainerAdapter.kt index 2934fba13..af05afb3c 100644 --- a/course/src/main/java/org/openedx/course/presentation/unit/container/CourseUnitContainerAdapter.kt +++ b/course/src/main/java/org/openedx/course/presentation/unit/container/CourseUnitContainerAdapter.kt @@ -7,6 +7,7 @@ import org.openedx.core.domain.model.Block import org.openedx.core.module.db.DownloadModel import org.openedx.course.presentation.unit.NotAvailableUnitFragment import org.openedx.course.presentation.unit.NotAvailableUnitType +import org.openedx.course.presentation.unit.PrerequisiteLockedFragment import org.openedx.course.presentation.unit.html.HtmlUnitFragment import org.openedx.course.presentation.unit.video.VideoUnitFragment import org.openedx.course.presentation.unit.video.YoutubeVideoUnitFragment @@ -30,6 +31,14 @@ class CourseUnitContainerAdapter( val noNetwork = !viewModel.hasNetworkConnection return when { + isBlockGatedWithPrerequisite(block) -> { + createPrerequisiteLockedFragment(block) + } + + block.containsGatedContent && block.gatedContent == null -> { + createNotAvailableUnitFragment(block, NotAvailableUnitType.MOBILE_UNSUPPORTED) + } + isBlockNotDownloaded(block, noNetwork, offlineUrl) -> { createNotAvailableUnitFragment(block, NotAvailableUnitType.NOT_DOWNLOADED) } @@ -85,6 +94,11 @@ class CourseUnitContainerAdapter( block.isSurveyBlock } + private fun isBlockGatedWithPrerequisite(block: Block): Boolean { + val gatedContent = block.gatedContent + return gatedContent?.gated == true && gatedContent.prereqId.isNotEmpty() + } + private fun createHtmlUnitFragment( block: Block, downloadedModel: DownloadModel?, @@ -146,4 +160,12 @@ class CourseUnitContainerAdapter( block.id ) } + + private fun createPrerequisiteLockedFragment(block: Block): Fragment { + val gatedContent = block.gatedContent + return PrerequisiteLockedFragment.newInstance( + prereqId = gatedContent?.prereqId.orEmpty(), + prereqSectionName = gatedContent?.prereqSectionName.orEmpty(), + ) + } } diff --git a/course/src/main/java/org/openedx/course/presentation/unit/container/CourseUnitContainerViewModel.kt b/course/src/main/java/org/openedx/course/presentation/unit/container/CourseUnitContainerViewModel.kt index 353a1b0ff..cd8da5265 100644 --- a/course/src/main/java/org/openedx/course/presentation/unit/container/CourseUnitContainerViewModel.kt +++ b/course/src/main/java/org/openedx/course/presentation/unit/container/CourseUnitContainerViewModel.kt @@ -97,6 +97,12 @@ class CourseUnitContainerViewModel( courseName = courseStructure.name this@CourseUnitContainerViewModel.blocks.clearAndAddAll(blocks) + blocks.find { it.id == unitId }?.let { unitBlock -> + if (unitBlock.containsGatedContent && unitBlock.descendants.isNotEmpty()) { + val firstDesc = blocks.firstOrNull { it.id == unitBlock.descendants.first() } + } + } + setupCurrentIndex(componentId) } catch (e: Exception) { e.printStackTrace() @@ -131,6 +137,17 @@ class CourseUnitContainerViewModel( currentSectionIndex = blocks.indexOfFirst { it.descendants.contains(blocks[currentVerticalIndex].id) } + val blockGatedContent = block.gatedContent + val isBlockGatedWithPrereq = blockGatedContent?.gated == true && blockGatedContent.prereqId.isNotEmpty() + + val firstDescendant = if (!isBlockGatedWithPrereq && block.descendants.isNotEmpty()) { + blocks.firstOrNull { it.id == block.descendants.first() } + } else null + + val firstDescGatedContent = firstDescendant?.gatedContent + val firstDescGatedWithPrereq = firstDescGatedContent?.gated == true && + firstDescGatedContent.prereqId.isNotEmpty() + if (block.descendants.isNotEmpty() || block.isGated()) { _descendantsBlocks.value = block.descendants.mapNotNull { descendant -> @@ -139,8 +156,13 @@ class CourseUnitContainerViewModel( _subSectionUnitBlocks.value = getSubSectionUnitBlocks(blocks, getSubSectionId(unitId)) - if (_descendantsBlocks.value.isEmpty()) { - _descendantsBlocks.value = listOf(block) + when { + _descendantsBlocks.value.isEmpty() || isBlockGatedWithPrereq -> { + _descendantsBlocks.value = listOf(block) + } + firstDescGatedWithPrereq && firstDescendant != null -> { + _descendantsBlocks.value = listOf(firstDescendant) + } } } else { setNextVerticalIndex() diff --git a/course/src/main/res/values/strings.xml b/course/src/main/res/values/strings.xml index 59c536295..81e2b9ef1 100644 --- a/course/src/main/res/values/strings.xml +++ b/course/src/main/res/values/strings.xml @@ -34,6 +34,9 @@ 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 From ae99ac4b8b39dc17c5ac11efc5921f6df5586d5b Mon Sep 17 00:00:00 2001 From: Yogesh Bhagat Date: Thu, 30 Oct 2025 13:30:09 +0530 Subject: [PATCH 2/6] add navigation to prerequisite subsection from locked unit --- .../unit/PrerequisiteLockedFragment.kt | 23 +++++++++++++++---- .../container/CourseUnitContainerAdapter.kt | 2 ++ .../container/CourseUnitContainerViewModel.kt | 3 +++ 3 files changed, 24 insertions(+), 4 deletions(-) diff --git a/course/src/main/java/org/openedx/course/presentation/unit/PrerequisiteLockedFragment.kt b/course/src/main/java/org/openedx/course/presentation/unit/PrerequisiteLockedFragment.kt index 7a6dfe1f5..4eb8237fa 100644 --- a/course/src/main/java/org/openedx/course/presentation/unit/PrerequisiteLockedFragment.kt +++ b/course/src/main/java/org/openedx/course/presentation/unit/PrerequisiteLockedFragment.kt @@ -20,18 +20,19 @@ import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.fragment.app.Fragment +import org.koin.android.ext.android.inject +import org.openedx.core.presentation.course.CourseViewMode import org.openedx.core.ui.OpenEdXButton import org.openedx.core.ui.theme.OpenEdXTheme import org.openedx.core.ui.theme.appColors import org.openedx.core.ui.theme.appTypography +import org.openedx.course.presentation.CourseRouter import org.openedx.foundation.extension.parcelable import org.openedx.course.R as courseR class PrerequisiteLockedFragment : Fragment() { - interface PrerequisiteNavigationListener { - fun navigateToPrerequisite(prereqId: String) - } + private val router by inject() override fun onCreateView( inflater: LayoutInflater, @@ -46,6 +47,16 @@ class PrerequisiteLockedFragment : Fragment() { PrerequisiteLockedScreen( prereqSectionName = prereqData?.prereqSectionName ?: "", onNavigateToPrerequisite = { + prereqData?.let { data -> + router.navigateToCourseSubsections( + fm = requireActivity().supportFragmentManager, + courseId = data.courseId, + subSectionId = data.prereqId, + unitId = "", + componentId = "", + mode = data.mode + ) + } } ) } @@ -56,14 +67,16 @@ class PrerequisiteLockedFragment : Fragment() { private const val ARG_PREREQUISITE_DATA = "prerequisite_data" fun newInstance( + courseId: String, prereqId: String, prereqSectionName: String, + mode: CourseViewMode ): PrerequisiteLockedFragment { val fragment = PrerequisiteLockedFragment() fragment.arguments = Bundle().apply { putParcelable( ARG_PREREQUISITE_DATA, - PrerequisiteData(prereqId, prereqSectionName) + PrerequisiteData(courseId, prereqId, prereqSectionName, mode) ) } return fragment @@ -73,8 +86,10 @@ class PrerequisiteLockedFragment : Fragment() { @kotlinx.parcelize.Parcelize data class PrerequisiteData( + val courseId: String, val prereqId: String, val prereqSectionName: String, + val mode: CourseViewMode ) : android.os.Parcelable @Composable diff --git a/course/src/main/java/org/openedx/course/presentation/unit/container/CourseUnitContainerAdapter.kt b/course/src/main/java/org/openedx/course/presentation/unit/container/CourseUnitContainerAdapter.kt index af05afb3c..577e2ab6d 100644 --- a/course/src/main/java/org/openedx/course/presentation/unit/container/CourseUnitContainerAdapter.kt +++ b/course/src/main/java/org/openedx/course/presentation/unit/container/CourseUnitContainerAdapter.kt @@ -164,8 +164,10 @@ class CourseUnitContainerAdapter( private fun createPrerequisiteLockedFragment(block: Block): Fragment { val gatedContent = block.gatedContent return PrerequisiteLockedFragment.newInstance( + courseId = viewModel.courseId, prereqId = gatedContent?.prereqId.orEmpty(), prereqSectionName = gatedContent?.prereqSectionName.orEmpty(), + mode = viewModel.mode ) } } diff --git a/course/src/main/java/org/openedx/course/presentation/unit/container/CourseUnitContainerViewModel.kt b/course/src/main/java/org/openedx/course/presentation/unit/container/CourseUnitContainerViewModel.kt index cd8da5265..c1e268714 100644 --- a/course/src/main/java/org/openedx/course/presentation/unit/container/CourseUnitContainerViewModel.kt +++ b/course/src/main/java/org/openedx/course/presentation/unit/container/CourseUnitContainerViewModel.kt @@ -76,6 +76,9 @@ class CourseUnitContainerViewModel( var hasNextBlock = false private var currentMode: CourseViewMode? = null + val mode: CourseViewMode + get() = currentMode ?: CourseViewMode.FULL + private var currentComponentId = "" private var courseName = "" From 86e03ae07f45a914deb00a449720581180cfc2b2 Mon Sep 17 00:00:00 2001 From: Yogesh Bhagat Date: Mon, 3 Nov 2025 19:33:36 +0530 Subject: [PATCH 3/6] add lock icon in course outline for locked section due to prerequisite --- .../presentation/section/CourseSectionFragment.kt | 14 +++++++++++++- .../org/openedx/course/presentation/ui/CourseUI.kt | 10 ++++++++++ course/src/main/res/values/strings.xml | 1 + 3 files changed, 24 insertions(+), 1 deletion(-) 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..89393c26a 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 @@ -288,15 +288,27 @@ private fun CourseSubsectionItem( val completedIconPainter = if (block.isCompleted()) { painterResource(R.drawable.course_ic_task_alt) + } + else if (block.isGated()) { + painterResource(R.drawable.ic_lock) } else { painterResource( CoreR.drawable.ic_core_chapter_icon ) } val completedIconColor = - if (block.isCompleted()) MaterialTheme.appColors.primary else MaterialTheme.appColors.onSurface + if (block.isCompleted()) { + MaterialTheme.appColors.primary + } + else if (block.isGated()) { + MaterialTheme.appColors.textSecondary + } else { + MaterialTheme.appColors.onSurface + } val completedIconDescription = if (block.isCompleted()) { stringResource(id = R.string.course_accessibility_section_completed) + } else if (block.isGated()) { + stringResource(id = R.string.course_accessibility_section_locked) } else { stringResource(id = R.string.course_accessibility_section_uncompleted) } diff --git a/course/src/main/java/org/openedx/course/presentation/ui/CourseUI.kt b/course/src/main/java/org/openedx/course/presentation/ui/CourseUI.kt index f0a87d8f4..bdab684d2 100644 --- a/course/src/main/java/org/openedx/course/presentation/ui/CourseUI.kt +++ b/course/src/main/java/org/openedx/course/presentation/ui/CourseUI.kt @@ -136,16 +136,22 @@ fun CourseSectionCard( ) { val completedIconPainter = if (block.isCompleted()) { painterResource(R.drawable.course_ic_task_alt) + } else if (block.isGated()) { + painterResource(R.drawable.ic_lock) } else { painterResource(coreR.drawable.ic_core_chapter_icon) } val completedIconColor = if (block.isCompleted()) { MaterialTheme.appColors.primary + } else if (block.isGated()) { + MaterialTheme.appColors.textSecondary } else { MaterialTheme.appColors.onSurface } val completedIconDescription = if (block.isCompleted()) { stringResource(id = R.string.course_accessibility_section_completed) + } else if (block.isGated()) { + stringResource(id = R.string.course_accessibility_section_locked) } else { stringResource(id = R.string.course_accessibility_section_uncompleted) } @@ -780,11 +786,15 @@ fun CourseSubSectionItem( val context = LocalContext.current val icon = if (block.isCompleted()) { painterResource(R.drawable.course_ic_task_alt) + } else if (block.isGated()) { + painterResource(R.drawable.ic_lock) } else { painterResource(coreR.drawable.ic_core_chapter_icon) } val iconColor = if (block.isCompleted()) { MaterialTheme.appColors.successGreen + } else if (block.isGated()) { + MaterialTheme.appColors.textSecondary } else { MaterialTheme.appColors.onSurface } diff --git a/course/src/main/res/values/strings.xml b/course/src/main/res/values/strings.xml index 81e2b9ef1..cf845f0a4 100644 --- a/course/src/main/res/values/strings.xml +++ b/course/src/main/res/values/strings.xml @@ -57,6 +57,7 @@ Stop downloading course section Section completed Section uncompleted + Section locked Downloads (Untitled) From 5411285d40f89048224166c6c58667bed21069bf Mon Sep 17 00:00:00 2001 From: Yogesh Bhagat Date: Mon, 3 Nov 2025 19:39:17 +0530 Subject: [PATCH 4/6] hide navigation buttons for locked units --- .../container/CourseUnitContainerFragment.kt | 45 +++++++++++-------- 1 file changed, 26 insertions(+), 19 deletions(-) diff --git a/course/src/main/java/org/openedx/course/presentation/unit/container/CourseUnitContainerFragment.kt b/course/src/main/java/org/openedx/course/presentation/unit/container/CourseUnitContainerFragment.kt index c8ea5de29..beae18ba6 100644 --- a/course/src/main/java/org/openedx/course/presentation/unit/container/CourseUnitContainerFragment.kt +++ b/course/src/main/java/org/openedx/course/presentation/unit/container/CourseUnitContainerFragment.kt @@ -452,26 +452,33 @@ class CourseUnitContainerFragment : Fragment(R.layout.fragment_course_unit_conta hasNextBlock = hasNext } - NavigationUnitsButtons( - hasPrevBlock = hasPrevBlock, - nextButtonText = nextButtonText, - hasNextBlock = hasNextBlock, - isVerticalNavigation = !viewModel.isCourseUnitProgressEnabled, - onPrevClick = { - handlePrevClick { next, hasPrev, hasNext -> - nextButtonText = next - hasPrevBlock = hasPrev - hasNextBlock = hasNext - } - }, - onNextClick = { - handleNextClick { next, hasPrev, hasNext -> - nextButtonText = next - hasPrevBlock = hasPrev - hasNextBlock = hasNext + val currentIndex by viewModel.indexInContainer.observeAsState(0) + val descendantsBlocks by viewModel.descendantsBlocks.collectAsState() + val currentDisplayedBlock = descendantsBlocks.getOrNull(currentIndex) + val isContentLocked = currentDisplayedBlock?.isGated() ?: false + + if (!isContentLocked) { + NavigationUnitsButtons( + hasPrevBlock = hasPrevBlock, + nextButtonText = nextButtonText, + hasNextBlock = hasNextBlock, + isVerticalNavigation = !viewModel.isCourseUnitProgressEnabled, + onPrevClick = { + handlePrevClick { next, hasPrev, hasNext -> + nextButtonText = next + hasPrevBlock = hasPrev + hasNextBlock = hasNext + } + }, + onNextClick = { + handleNextClick { next, hasPrev, hasNext -> + nextButtonText = next + hasPrevBlock = hasPrev + hasNextBlock = hasNext + } } - } - ) + ) + } } } From 6140233695b61539ae73ad65aa1b54b3483055d8 Mon Sep 17 00:00:00 2001 From: Yogesh Bhagat Date: Wed, 5 Nov 2025 20:00:26 +0530 Subject: [PATCH 5/6] check prerequisite completion when viewing locked content --- .../container/CourseUnitContainerAdapter.kt | 2 + .../container/CourseUnitContainerFragment.kt | 52 ++++++++++- .../container/CourseUnitContainerViewModel.kt | 90 ++++++++++++++++--- 3 files changed, 130 insertions(+), 14 deletions(-) diff --git a/course/src/main/java/org/openedx/course/presentation/unit/container/CourseUnitContainerAdapter.kt b/course/src/main/java/org/openedx/course/presentation/unit/container/CourseUnitContainerAdapter.kt index 577e2ab6d..d3faa86bf 100644 --- a/course/src/main/java/org/openedx/course/presentation/unit/container/CourseUnitContainerAdapter.kt +++ b/course/src/main/java/org/openedx/course/presentation/unit/container/CourseUnitContainerAdapter.kt @@ -25,6 +25,8 @@ class CourseUnitContainerAdapter( override fun createFragment(position: Int): Fragment = unitBlockFragment(blocks[position]) + fun getBlock(position: Int): Block = blocks[position] + private fun unitBlockFragment(block: Block): Fragment { val downloadedModel = viewModel.getDownloadModelById(block.id) val offlineUrl = downloadedModel?.let { it.path + File.separator + "index.html" } ?: "" diff --git a/course/src/main/java/org/openedx/course/presentation/unit/container/CourseUnitContainerFragment.kt b/course/src/main/java/org/openedx/course/presentation/unit/container/CourseUnitContainerFragment.kt index beae18ba6..ab483a537 100644 --- a/course/src/main/java/org/openedx/course/presentation/unit/container/CourseUnitContainerFragment.kt +++ b/course/src/main/java/org/openedx/course/presentation/unit/container/CourseUnitContainerFragment.kt @@ -159,6 +159,7 @@ class CourseUnitContainerFragment : Fragment(R.layout.fragment_course_unit_conta setupSubSectionUnits() checkUnitsListShown() setupChapterEndDialogListener() + observeDataChanges() } private fun setupViewPagerInsets() { @@ -325,6 +326,55 @@ class CourseUnitContainerFragment : Fragment(R.layout.fragment_course_unit_conta override fun onResume() { super.onResume() activity?.onBackPressedDispatcher?.addCallback(onBackPressedCallback) + + // Only check prerequisite completion if: + // 1. We were viewing locked content + // 2. There was a tracked prerequisite + // 3. That prerequisite was incomplete when we first loaded + // This ensures we only make API calls when there's a real possibility of completion + lifecycleScope.launch { + if (viewModel.shouldRefreshForPrerequisiteCompletion()) { + // The prerequisite subsection is now fully complete, refresh data + viewModel.refreshCourseData() + } + } + } + + private fun observeDataChanges() { + viewLifecycleOwner.lifecycleScope.launch { + viewModel.descendantsBlocks.collect { blocks -> + if (blocks.isNotEmpty() && ::adapter.isInitialized) { + val wasShowingLockedContent = adapter.itemCount == 1 && + adapter.getBlock(0).let { block -> + block.gatedContent?.gated == true || + (block.type == BlockType.VERTICAL && block.descendants.isEmpty()) + } + + // Update adapter when blocks change (e.g., after refreshing from prerequisite) + adapter = CourseUnitContainerAdapter(this@CourseUnitContainerFragment, blocks, viewModel) + binding.viewPager.adapter = adapter + + // If we were showing locked content and now have unlocked content, navigate to it + if (wasShowingLockedContent && blocks.size > 1) { + // Content is now unlocked, set to first item + binding.viewPager.setCurrentItem(0, true) + } else if (wasShowingLockedContent && blocks.size == 1) { + // Check if the single block is no longer locked + val currentBlock = blocks[0] + val isStillLocked = currentBlock.gatedContent?.gated == true + if (!isStillLocked && currentBlock.descendants.isNotEmpty()) { + // Content is now unlocked, navigate to the actual content + router.navigateToCourseContainer( + fm = requireActivity().supportFragmentManager, + courseId = viewModel.courseId, + unitId = currentBlock.id, + mode = requireArguments().serializable(ARG_MODE)!! + ) + } + } + } + } + } } override fun onPause() { @@ -422,7 +472,7 @@ class CourseUnitContainerFragment : Fragment(R.layout.fragment_course_unit_conta } private fun handleUnitsClick() { - if (binding.subSectionUnitsList.visibility == View.VISIBLE) { + if (binding.subSectionUnitsList.isVisible) { binding.subSectionUnitsList.visibility = View.GONE binding.subSectionUnitsBg.visibility = View.GONE viewModel.setUnitsListVisibility(false) diff --git a/course/src/main/java/org/openedx/course/presentation/unit/container/CourseUnitContainerViewModel.kt b/course/src/main/java/org/openedx/course/presentation/unit/container/CourseUnitContainerViewModel.kt index c1e268714..df2434497 100644 --- a/course/src/main/java/org/openedx/course/presentation/unit/container/CourseUnitContainerViewModel.kt +++ b/course/src/main/java/org/openedx/course/presentation/unit/container/CourseUnitContainerViewModel.kt @@ -39,6 +39,13 @@ class CourseUnitContainerViewModel( private val blocks = ArrayList() + private var isRefreshing = false + + // Track when we're viewing locked content due to prerequisites + private var isViewingLockedContent = false + private var trackedPrereqId: String? = null + private var wasPrereqIncomplete = false + val isCourseExpandableSectionsEnabled get() = config.getCourseUIConfig().isCourseDropdownNavigationEnabled val isCourseUnitProgressEnabled get() = config.getCourseUIConfig().isCourseUnitProgressEnabled @@ -88,24 +95,17 @@ class CourseUnitContainerViewModel( val hasNetworkConnection: Boolean get() = networkConnection.isOnline() - fun loadBlocks(mode: CourseViewMode, componentId: String = "") { + fun loadBlocks(mode: CourseViewMode, componentId: String = "", forceRefresh: Boolean = false) { currentMode = mode viewModelScope.launch { try { val courseStructure = when (mode) { - CourseViewMode.FULL -> interactor.getCourseStructure(courseId) + CourseViewMode.FULL -> interactor.getCourseStructure(courseId, isNeedRefresh = forceRefresh) CourseViewMode.VIDEOS -> interactor.getCourseStructureForVideos(courseId) } val blocks = courseStructure.blockData courseName = courseStructure.name this@CourseUnitContainerViewModel.blocks.clearAndAddAll(blocks) - - blocks.find { it.id == unitId }?.let { unitBlock -> - if (unitBlock.containsGatedContent && unitBlock.descendants.isNotEmpty()) { - val firstDesc = blocks.firstOrNull { it.id == unitBlock.descendants.first() } - } - } - setupCurrentIndex(componentId) } catch (e: Exception) { e.printStackTrace() @@ -113,6 +113,48 @@ class CourseUnitContainerViewModel( } } + fun refreshCourseData() { + currentMode?.let { mode -> + // Reset the current section index so setupCurrentIndex will run properly + isRefreshing = true + currentSectionIndex = -1 + loadBlocks(mode, currentComponentId, forceRefresh = true) + } + } + + /** + * Check if the tracked prerequisite has been completed. + * Only performs API call if we were tracking a prerequisite that was incomplete. + * Returns true if prerequisite completion status changed from incomplete to complete. + */ + suspend fun shouldRefreshForPrerequisiteCompletion(): Boolean { + // Only check if we were actually viewing locked content with a tracked prerequisite + if (!isViewingLockedContent || trackedPrereqId == null || !wasPrereqIncomplete) { + return false + } + + try { + // Fetch fresh data to check completion + val courseStructure = when (currentMode) { + CourseViewMode.FULL -> interactor.getCourseStructure(courseId, isNeedRefresh = true) + CourseViewMode.VIDEOS -> interactor.getCourseStructureForVideos(courseId) + else -> return false + } + + // Find the prerequisite subsection in fresh data + val prereqBlock = courseStructure.blockData.firstOrNull { it.id == trackedPrereqId } + + // Check if it's now complete (completion = 1.0 means all units done) + val isNowComplete = prereqBlock?.completion == 1.0 + + // Return true only if it changed from incomplete to complete + return isNowComplete && wasPrereqIncomplete + } catch (e: Exception) { + e.printStackTrace() + return false + } + } + init { _indexInContainer.value = 0 @@ -131,7 +173,8 @@ class CourseUnitContainerViewModel( } private fun setupCurrentIndex(componentId: String = "") { - if (currentSectionIndex != -1) return + if (currentSectionIndex != -1 && !isRefreshing) return + isRefreshing = false currentComponentId = componentId blocks.forEachIndexed { index, block -> @@ -141,7 +184,20 @@ class CourseUnitContainerViewModel( it.descendants.contains(blocks[currentVerticalIndex].id) } val blockGatedContent = block.gatedContent - val isBlockGatedWithPrereq = blockGatedContent?.gated == true && blockGatedContent.prereqId.isNotEmpty() + val isBlockGatedWithPrereq = blockGatedContent?.gated == true && + blockGatedContent.prereqId.isNotEmpty() + + // Mark if we're viewing locked content (will be used to decide if we need to check completion later) + if (isBlockGatedWithPrereq && blockGatedContent != null) { + isViewingLockedContent = true + trackedPrereqId = blockGatedContent.prereqId + val prereqBlock = blocks.firstOrNull { it.id == blockGatedContent.prereqId } + wasPrereqIncomplete = prereqBlock?.completion != 1.0 + } else { + isViewingLockedContent = false + trackedPrereqId = null + wasPrereqIncomplete = false + } val firstDescendant = if (!isBlockGatedWithPrereq && block.descendants.isNotEmpty()) { blocks.firstOrNull { it.id == block.descendants.first() } @@ -151,6 +207,14 @@ class CourseUnitContainerViewModel( val firstDescGatedWithPrereq = firstDescGatedContent?.gated == true && firstDescGatedContent.prereqId.isNotEmpty() + // Also check first descendant for locked content + if (firstDescGatedWithPrereq && !isViewingLockedContent && firstDescGatedContent != null) { + isViewingLockedContent = true + trackedPrereqId = firstDescGatedContent.prereqId + val prereqBlock = blocks.firstOrNull { it.id == firstDescGatedContent.prereqId } + wasPrereqIncomplete = prereqBlock?.completion != 1.0 + } + if (block.descendants.isNotEmpty() || block.isGated()) { _descendantsBlocks.value = block.descendants.mapNotNull { descendant -> @@ -163,8 +227,8 @@ class CourseUnitContainerViewModel( _descendantsBlocks.value.isEmpty() || isBlockGatedWithPrereq -> { _descendantsBlocks.value = listOf(block) } - firstDescGatedWithPrereq && firstDescendant != null -> { - _descendantsBlocks.value = listOf(firstDescendant) + firstDescGatedWithPrereq -> { + _descendantsBlocks.value = listOfNotNull(firstDescendant) } } } else { From 2c8e5d6e5e5752df44ade387b99672e946ce1b73 Mon Sep 17 00:00:00 2001 From: Yogesh Bhagat Date: Thu, 20 Nov 2025 16:42:49 +0530 Subject: [PATCH 6/6] fix browser redirect issue after prerequisite is locked due to incorrect answer --- .../outline/CourseOutlineViewModel.kt | 6 +- .../container/CourseUnitContainerAdapter.kt | 22 +++++-- .../container/CourseUnitContainerFragment.kt | 14 ++--- .../container/CourseUnitContainerViewModel.kt | 62 ++++++++++++++++++- .../unit/html/HtmlUnitFragment.kt | 54 ++++++++++++++-- .../unit/html/HtmlUnitViewModel.kt | 7 +++ 6 files changed, 148 insertions(+), 17 deletions(-) diff --git a/course/src/main/java/org/openedx/course/presentation/outline/CourseOutlineViewModel.kt b/course/src/main/java/org/openedx/course/presentation/outline/CourseOutlineViewModel.kt index 4b373b05f..f28013272 100644 --- a/course/src/main/java/org/openedx/course/presentation/outline/CourseOutlineViewModel.kt +++ b/course/src/main/java/org/openedx/course/presentation/outline/CourseOutlineViewModel.kt @@ -240,12 +240,16 @@ class CourseOutlineViewModel( val courseSectionsState = (_uiState.value as? CourseOutlineUIState.CourseData)?.courseSectionsState.orEmpty() + // Create immutable copy of courseSubSections so Compose detects changes + // This is important for updating lock icons when prerequisite status changes + val courseSubSectionsCopy = courseSubSections.toMap() + _uiState.value = CourseOutlineUIState.CourseData( courseStructure = sortedStructure, downloadedState = getDownloadModelsStatus(), resumeComponent = getResumeBlock(blocks, courseStatus.lastVisitedBlockId), resumeUnitTitle = resumeVerticalBlock?.displayName ?: "", - courseSubSections = courseSubSections, + courseSubSections = courseSubSectionsCopy, courseSectionsState = courseSectionsState, subSectionsDownloadsCount = subSectionsDownloadsCount, datesBannerInfo = datesBannerInfo, diff --git a/course/src/main/java/org/openedx/course/presentation/unit/container/CourseUnitContainerAdapter.kt b/course/src/main/java/org/openedx/course/presentation/unit/container/CourseUnitContainerAdapter.kt index d3faa86bf..8048d468f 100644 --- a/course/src/main/java/org/openedx/course/presentation/unit/container/CourseUnitContainerAdapter.kt +++ b/course/src/main/java/org/openedx/course/presentation/unit/container/CourseUnitContainerAdapter.kt @@ -27,6 +27,24 @@ class CourseUnitContainerAdapter( fun getBlock(position: Int): Block = blocks[position] + // Override getItemId to use block ID as unique identifier + // This ensures fragments are recreated when block gated status changes + override fun getItemId(position: Int): Long { + val block = blocks[position] + // Create a unique ID based on block ID and its gated status + // This forces fragment recreation when gated status changes + val gatedSuffix = if (isBlockGatedWithPrerequisite(block)) "_gated" else "_open" + return (block.id + gatedSuffix).hashCode().toLong() + } + + // Override containsItem to check if block still exists + override fun containsItem(itemId: Long): Boolean { + return blocks.any { block -> + val gatedSuffix = if (isBlockGatedWithPrerequisite(block)) "_gated" else "_open" + (block.id + gatedSuffix).hashCode().toLong() == itemId + } + } + private fun unitBlockFragment(block: Block): Fragment { val downloadedModel = viewModel.getDownloadModelById(block.id) val offlineUrl = downloadedModel?.let { it.path + File.separator + "index.html" } ?: "" @@ -37,10 +55,6 @@ class CourseUnitContainerAdapter( createPrerequisiteLockedFragment(block) } - block.containsGatedContent && block.gatedContent == null -> { - createNotAvailableUnitFragment(block, NotAvailableUnitType.MOBILE_UNSUPPORTED) - } - isBlockNotDownloaded(block, noNetwork, offlineUrl) -> { createNotAvailableUnitFragment(block, NotAvailableUnitType.NOT_DOWNLOADED) } diff --git a/course/src/main/java/org/openedx/course/presentation/unit/container/CourseUnitContainerFragment.kt b/course/src/main/java/org/openedx/course/presentation/unit/container/CourseUnitContainerFragment.kt index ab483a537..fdaa7c2b8 100644 --- a/course/src/main/java/org/openedx/course/presentation/unit/container/CourseUnitContainerFragment.kt +++ b/course/src/main/java/org/openedx/course/presentation/unit/container/CourseUnitContainerFragment.kt @@ -327,14 +327,14 @@ class CourseUnitContainerFragment : Fragment(R.layout.fragment_course_unit_conta super.onResume() activity?.onBackPressedDispatcher?.addCallback(onBackPressedCallback) - // Only check prerequisite completion if: - // 1. We were viewing locked content - // 2. There was a tracked prerequisite - // 3. That prerequisite was incomplete when we first loaded - // This ensures we only make API calls when there's a real possibility of completion + // Refresh if we're viewing locked content, to ensure we have the latest lock status + // This handles the case where user completes prerequisite and navigates back lifecycleScope.launch { - if (viewModel.shouldRefreshForPrerequisiteCompletion()) { - // The prerequisite subsection is now fully complete, refresh data + if (viewModel.checkIsViewingLockedContent()) { + // Always refresh when viewing locked content to check if it's now unlocked + viewModel.refreshCourseData() + } else if (viewModel.shouldRefreshForPrerequisiteCompletion()) { + // Fallback: check if prerequisite was completed (for other scenarios) viewModel.refreshCourseData() } } diff --git a/course/src/main/java/org/openedx/course/presentation/unit/container/CourseUnitContainerViewModel.kt b/course/src/main/java/org/openedx/course/presentation/unit/container/CourseUnitContainerViewModel.kt index df2434497..72181b4fe 100644 --- a/course/src/main/java/org/openedx/course/presentation/unit/container/CourseUnitContainerViewModel.kt +++ b/course/src/main/java/org/openedx/course/presentation/unit/container/CourseUnitContainerViewModel.kt @@ -99,14 +99,66 @@ class CourseUnitContainerViewModel( currentMode = mode viewModelScope.launch { try { + // First, check if we need to force refresh for prerequisite-gated content + var shouldForceRefresh = forceRefresh + + if (!forceRefresh) { + // Get preliminary structure to check block type + val preliminaryStructure = when (mode) { + CourseViewMode.FULL -> interactor.getCourseStructure(courseId, isNeedRefresh = false) + CourseViewMode.VIDEOS -> interactor.getCourseStructureForVideos(courseId) + } + + val targetBlock = preliminaryStructure.blockData.firstOrNull { it.id == unitId } + + // Check if this block or its first descendant could be gated + // We check both gatedContent presence AND block type, because: + // 1. If gatedContent exists, we know it's related to prerequisites + // 2. If it's a problem/assessment block, it COULD become gated after wrong answers + val hasGatedContent = targetBlock?.gatedContent != null + val couldBeGated = targetBlock?.let { block -> + block.isProblemBlock || + block.isOpenAssessmentBlock || + block.isLTIConsumerBlock || + block.isSurveyBlock + } ?: false + + val firstDescendant = if (targetBlock?.descendants?.isNotEmpty() == true) { + preliminaryStructure.blockData.firstOrNull { it.id == targetBlock.descendants.first() } + } else null + val firstDescHasGatedContent = firstDescendant?.gatedContent != null + val firstDescCouldBeGated = firstDescendant?.let { block -> + block.isProblemBlock || + block.isOpenAssessmentBlock || + block.isLTIConsumerBlock || + block.isSurveyBlock + } ?: false + + // Force refresh if: + // - Block has gatedContent (we know it's related to prerequisites) + // - Block is a type that could be gated (problem, assessment, etc.) + if (hasGatedContent || couldBeGated || firstDescHasGatedContent || firstDescCouldBeGated) { + // Force refresh to ensure we have the latest lock status from backend + shouldForceRefresh = true + } + } + val courseStructure = when (mode) { - CourseViewMode.FULL -> interactor.getCourseStructure(courseId, isNeedRefresh = forceRefresh) + CourseViewMode.FULL -> interactor.getCourseStructure(courseId, isNeedRefresh = shouldForceRefresh) CourseViewMode.VIDEOS -> interactor.getCourseStructureForVideos(courseId) } val blocks = courseStructure.blockData courseName = courseStructure.name this@CourseUnitContainerViewModel.blocks.clearAndAddAll(blocks) setupCurrentIndex(componentId) + + // Explicitly update subSectionUnitBlocks after refresh to ensure + // lock icons update when prerequisite status changes + if (blocks.isNotEmpty() && currentVerticalIndex != -1) { + val blockId = blocks[currentVerticalIndex].id + _subSectionUnitBlocks.value = + getSubSectionUnitBlocks(blocks, getSubSectionId(blockId)) + } } catch (e: Exception) { e.printStackTrace() } @@ -155,6 +207,14 @@ class CourseUnitContainerViewModel( } } + /** + * Returns true if we're currently viewing prerequisite-locked content. + * Used to determine if we should refresh data when resuming. + */ + fun checkIsViewingLockedContent(): Boolean { + return isViewingLockedContent + } + init { _indexInContainer.value = 0 diff --git a/course/src/main/java/org/openedx/course/presentation/unit/html/HtmlUnitFragment.kt b/course/src/main/java/org/openedx/course/presentation/unit/html/HtmlUnitFragment.kt index e41e2c4ec..22a9c5391 100644 --- a/course/src/main/java/org/openedx/course/presentation/unit/html/HtmlUnitFragment.kt +++ b/course/src/main/java/org/openedx/course/presentation/unit/html/HtmlUnitFragment.kt @@ -28,6 +28,7 @@ import androidx.compose.material.CircularProgressIndicator import androidx.compose.material.MaterialTheme import androidx.compose.material.Surface import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf @@ -38,7 +39,6 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip -import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.ComposeView import androidx.compose.ui.platform.LocalConfiguration import androidx.compose.ui.platform.LocalContext @@ -98,7 +98,12 @@ class HtmlUnitFragment : Fragment() { blockUrl = blockUrl, offlineUrl = offlineUrl, fromDownloadedContent = fromDownloadedContent, - isFragmentAdded = isAdded + isFragmentAdded = isAdded, + onPrerequisiteLocked = { _, _ -> + // Fallback: if somehow HtmlUnitFragment loaded with gated content, navigate back + // This should rarely happen now that we force API refresh for problem blocks + activity?.supportFragmentManager?.popBackStack() + } ) } } @@ -136,11 +141,22 @@ fun HtmlUnitView( offlineUrl: String, fromDownloadedContent: Boolean, isFragmentAdded: Boolean, + onPrerequisiteLocked: (prereqId: String, prereqSectionName: String) -> Unit = { _, _ -> }, ) { OpenEdXTheme { val context = LocalContext.current val windowSize = rememberWindowSize() + // Get block data and check if it's gated + var isBlockGated by remember { mutableStateOf(false) } + + // Check block status on composition + LaunchedEffect(Unit) { + val block = viewModel.getBlockData() + isBlockGated = block?.gatedContent?.gated == true && + !block.gatedContent?.prereqId.isNullOrEmpty() + } + var hasInternetConnection by remember { mutableStateOf(viewModel.isOnline) } @@ -200,6 +216,9 @@ fun HtmlUnitView( apiHostURL = viewModel.apiHostURL, isLoading = uiState is HtmlUnitUIState.Loading, injectJSList = injectJSList, + viewModel = viewModel, + isBlockGated = isBlockGated, + onPrerequisiteLocked = onPrerequisiteLocked, onCompletionSet = { viewModel.notifyCompletionSet() }, @@ -254,6 +273,9 @@ private fun HTMLContentView( apiHostURL: String, isLoading: Boolean, injectJSList: List, + viewModel: HtmlUnitViewModel, + isBlockGated: Boolean, + onPrerequisiteLocked: (prereqId: String, prereqSectionName: String) -> Unit, onCompletionSet: () -> Unit, onWebPageLoading: () -> Unit, onWebPageLoaded: () -> Unit, @@ -319,9 +341,33 @@ private fun HTMLContentView( request: WebResourceRequest? ): Boolean { val clickUrl = request?.url?.toString() ?: "" + + // If block is gated with prerequisite, check and show locked fragment instead of browser + if (isBlockGated) { + coroutineScope.launch { + val block = viewModel.getBlockData() + val gatedContent = block?.gatedContent + + if (gatedContent?.gated == true && !gatedContent.prereqId.isNullOrEmpty()) { + // Content is prerequisite-locked, trigger callback to show locked fragment + onPrerequisiteLocked( + gatedContent.prereqId ?: "", + gatedContent.prereqSectionName ?: "" + ) + } + } + // Don't allow any redirects for gated content + return true + } + return if (clickUrl.isNotEmpty() && clickUrl.startsWith("http")) { - context.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(clickUrl))) - true + if (clickUrl.startsWith(apiHostURL)) { + // Allow internal course content links to load in WebView + false + } else { + context.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(clickUrl))) + true + } } else if (clickUrl.startsWith("mailto:")) { val email = clickUrl.replace("mailto:", "") if (email.isEmailValid()) { diff --git a/course/src/main/java/org/openedx/course/presentation/unit/html/HtmlUnitViewModel.kt b/course/src/main/java/org/openedx/course/presentation/unit/html/HtmlUnitViewModel.kt index 702082746..9caa5123e 100644 --- a/course/src/main/java/org/openedx/course/presentation/unit/html/HtmlUnitViewModel.kt +++ b/course/src/main/java/org/openedx/course/presentation/unit/html/HtmlUnitViewModel.kt @@ -111,4 +111,11 @@ class HtmlUnitViewModel( } } } + + suspend fun getBlockData() = try { + courseInteractor.getCourseStructure(courseId, false) + .blockData.find { it.id == blockId } + } catch (e: Exception) { + null + } }