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/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/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/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..4eb8237fa --- /dev/null +++ b/course/src/main/java/org/openedx/course/presentation/unit/PrerequisiteLockedFragment.kt @@ -0,0 +1,163 @@ +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.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() { + + private val router by inject() + + 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 = { + prereqData?.let { data -> + router.navigateToCourseSubsections( + fm = requireActivity().supportFragmentManager, + courseId = data.courseId, + subSectionId = data.prereqId, + unitId = "", + componentId = "", + mode = data.mode + ) + } + } + ) + } + } + } + + companion object { + 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(courseId, prereqId, prereqSectionName, mode) + ) + } + return fragment + } + } +} + +@kotlinx.parcelize.Parcelize +data class PrerequisiteData( + val courseId: String, + val prereqId: String, + val prereqSectionName: String, + val mode: CourseViewMode +) : 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..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 @@ -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 @@ -24,12 +25,36 @@ class CourseUnitContainerAdapter( override fun createFragment(position: Int): Fragment = unitBlockFragment(blocks[position]) + 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" } ?: "" val noNetwork = !viewModel.hasNetworkConnection return when { + isBlockGatedWithPrerequisite(block) -> { + createPrerequisiteLockedFragment(block) + } + isBlockNotDownloaded(block, noNetwork, offlineUrl) -> { createNotAvailableUnitFragment(block, NotAvailableUnitType.NOT_DOWNLOADED) } @@ -85,6 +110,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 +176,14 @@ class CourseUnitContainerAdapter( block.id ) } + + 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/CourseUnitContainerFragment.kt b/course/src/main/java/org/openedx/course/presentation/unit/container/CourseUnitContainerFragment.kt index c8ea5de29..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 @@ -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) + + // 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.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() + } + } + } + + 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) @@ -452,26 +502,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 + } } - } - ) + ) + } } } 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..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 @@ -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 @@ -76,6 +83,9 @@ class CourseUnitContainerViewModel( var hasNextBlock = false private var currentMode: CourseViewMode? = null + val mode: CourseViewMode + get() = currentMode ?: CourseViewMode.FULL + private var currentComponentId = "" private var courseName = "" @@ -85,25 +95,126 @@ 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 { + // 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) + 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() } } } + 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 + } + } + + /** + * 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 @@ -122,7 +233,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 -> @@ -131,6 +243,38 @@ class CourseUnitContainerViewModel( currentSectionIndex = blocks.indexOfFirst { it.descendants.contains(blocks[currentVerticalIndex].id) } + val blockGatedContent = block.gatedContent + 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() } + } else null + + val firstDescGatedContent = firstDescendant?.gatedContent + 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 -> @@ -139,8 +283,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 -> { + _descendantsBlocks.value = listOfNotNull(firstDescendant) + } } } else { setNextVerticalIndex() 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 + } } diff --git a/course/src/main/res/values/strings.xml b/course/src/main/res/values/strings.xml index 59c536295..cf845f0a4 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 @@ -54,6 +57,7 @@ Stop downloading course section Section completed Section uncompleted + Section locked Downloads (Untitled)