diff --git a/core/src/main/java/org/openedx/core/domain/model/AssignmentCompletion.kt b/core/src/main/java/org/openedx/core/domain/model/AssignmentCompletion.kt new file mode 100644 index 000000000..601356193 --- /dev/null +++ b/core/src/main/java/org/openedx/core/domain/model/AssignmentCompletion.kt @@ -0,0 +1,55 @@ +package org.openedx.core.domain.model + +import org.openedx.core.BlockType + +/** + * Assignment subsections with library_content wrappers may not roll up completion on the LMS + * blocks API. Treat complete when all descendant problems are done. + */ +fun Block.isAssignmentCompleted(allBlocks: List): Boolean { + if (isCompleted()) return true + + val problems = collectDescendantBlocks(allBlocks).filter { it.type == BlockType.PROBLEM } + return problems.isNotEmpty() && problems.all { it.completion == 1.0 } +} + +fun Block.assignmentCompletion(allBlocks: List): Double { + if (isCompleted()) return 1.0 + + val problems = collectDescendantBlocks(allBlocks).filter { it.type == BlockType.PROBLEM } + if (problems.isEmpty()) return completion + + return problems.count { it.completion == 1.0 }.toDouble() / problems.size +} + +/** + * Prefer LMS course_progress (Sumac-style). Fall back to MCQ rollup when LMS total is missing. + */ +fun assignmentProgress(courseStructure: CourseStructure, assignments: List): Progress { + val lmsProgress = courseStructure.progress + return if (lmsProgress != null && lmsProgress.total > 0) { + lmsProgress + } else { + val blockData = courseStructure.blockData + Progress( + completed = assignments.count { it.isAssignmentCompleted(blockData) }, + total = assignments.size, + ) + } +} + +private fun Block.collectDescendantBlocks(allBlocks: List): List { + if (descendants.isEmpty() || allBlocks.isEmpty()) return emptyList() + + val blockMap = allBlocks.associateBy { it.id } + val result = mutableListOf() + + fun walk(blockId: String) { + val block = blockMap[blockId] ?: return + result.add(block) + block.descendants.forEach { walk(it) } + } + + descendants.forEach { walk(it) } + return result +} diff --git a/course/src/main/java/org/openedx/course/presentation/assignments/CourseAssignmentUIState.kt b/course/src/main/java/org/openedx/course/presentation/assignments/CourseAssignmentUIState.kt index 28da59f6d..f8003f13c 100644 --- a/course/src/main/java/org/openedx/course/presentation/assignments/CourseAssignmentUIState.kt +++ b/course/src/main/java/org/openedx/course/presentation/assignments/CourseAssignmentUIState.kt @@ -9,7 +9,8 @@ sealed class CourseAssignmentUIState { val groupedAssignments: Map>, val courseProgress: CourseProgress, val progress: Progress, - val sectionNames: Map + val sectionNames: Map, + val allBlocks: List, ) : CourseAssignmentUIState() data object Empty : CourseAssignmentUIState() data object Loading : CourseAssignmentUIState() diff --git a/course/src/main/java/org/openedx/course/presentation/assignments/CourseAssignmentViewModel.kt b/course/src/main/java/org/openedx/course/presentation/assignments/CourseAssignmentViewModel.kt index 7bbc574ac..70eb6736f 100644 --- a/course/src/main/java/org/openedx/course/presentation/assignments/CourseAssignmentViewModel.kt +++ b/course/src/main/java/org/openedx/course/presentation/assignments/CourseAssignmentViewModel.kt @@ -11,7 +11,7 @@ import kotlinx.coroutines.launch import org.openedx.core.domain.model.Block import org.openedx.core.domain.model.CourseProgress import org.openedx.core.domain.model.CourseStructure -import org.openedx.core.domain.model.Progress +import org.openedx.core.domain.model.assignmentProgress import org.openedx.core.system.notifier.CourseNotifier import org.openedx.core.system.notifier.CourseStructureUpdated import org.openedx.course.domain.interactor.CourseInteractor @@ -79,16 +79,15 @@ class CourseAssignmentViewModel( val grouped = filteredAssignments .groupBy { it.assignmentProgress?.assignmentType ?: "" } .toSortedMap(compareBy { assignmentTypeOrder.indexOf(it) }) - val completed = assignments.count { it.isCompleted() } - val total = assignments.size - val progress = Progress(completed, total) + val progress = assignmentProgress(courseStructure, filteredAssignments) val sectionName = - createAssignmentToChapterMapping(courseStructure.blockData, assignments) + createAssignmentToChapterMapping(courseStructure.blockData, filteredAssignments) _uiState.value = CourseAssignmentUIState.CourseData( groupedAssignments = grouped, courseProgress = courseProgress, progress = progress, - sectionNames = sectionName + sectionNames = sectionName, + allBlocks = courseStructure.blockData, ) } } diff --git a/course/src/main/java/org/openedx/course/presentation/assignments/CourseContentAssignmentScreen.kt b/course/src/main/java/org/openedx/course/presentation/assignments/CourseContentAssignmentScreen.kt index c8b4a86c0..93a5a854c 100644 --- a/course/src/main/java/org/openedx/course/presentation/assignments/CourseContentAssignmentScreen.kt +++ b/course/src/main/java/org/openedx/course/presentation/assignments/CourseContentAssignmentScreen.kt @@ -57,6 +57,8 @@ import androidx.fragment.app.FragmentManager import org.openedx.core.CoreMocks import org.openedx.core.domain.model.Block import org.openedx.core.domain.model.Progress +import org.openedx.core.domain.model.assignmentCompletion +import org.openedx.core.domain.model.isAssignmentCompleted import org.openedx.core.ui.theme.OpenEdXTheme import org.openedx.core.ui.theme.appColors import org.openedx.core.ui.theme.appShapes @@ -185,6 +187,7 @@ private fun CourseContentAssignmentScreen( gradeColor = gradeColor, assignments = blocks, sectionNames = uiState.sectionNames, + allBlocks = uiState.allBlocks, onAssignmentClick = onAssignmentClick, ) } @@ -202,18 +205,19 @@ private fun AssignmentGroupSection( sectionNames: Map, percentOfGrade: Int, gradeColor: Color, + allBlocks: List, onAssignmentClick: (Block) -> Unit, ) { val progress = Progress( total = assignments.size, - completed = assignments.filter { it.isCompleted() }.size + completed = assignments.count { it.isAssignmentCompleted(allBlocks) } ) val description = stringResource( id = R.string.course_completed_of, progress.completed, progress.total ) - val firstUncompletedId = assignments.firstOrNull { !it.isCompleted() }?.id + val firstUncompletedId = assignments.firstOrNull { !it.isAssignmentCompleted(allBlocks) }?.id var selectedId by rememberSaveable(label) { mutableStateOf(firstUncompletedId) } var isCompletedShown by rememberSaveable { mutableStateOf(false) } @@ -272,6 +276,7 @@ private fun AssignmentGroupSection( items(assignments) { assignment -> AssignmentButton( assignment = assignment, + allBlocks = allBlocks, isSelected = assignment.id == selectedId, onClick = { selectedId = assignment.id @@ -287,6 +292,7 @@ private fun AssignmentGroupSection( modifier = Modifier .padding(horizontal = 24.dp), assignment = assignment, + allBlocks = allBlocks, sectionName = sectionNames[assignment.id] ?: "", onAssignmentClick = onAssignmentClick ) @@ -298,6 +304,7 @@ private fun AssignmentGroupSection( .padding(horizontal = 24.dp) .padding(top = 12.dp), assignment = assignment, + allBlocks = allBlocks, sectionName = sectionNames[assignment.id] ?: "", onAssignmentClick = onAssignmentClick ) @@ -311,21 +318,27 @@ private fun AssignmentGroupSection( } @Composable -private fun AssignmentButton(assignment: Block, isSelected: Boolean, onClick: () -> Unit) { +private fun AssignmentButton( + assignment: Block, + allBlocks: List, + isSelected: Boolean, + onClick: () -> Unit, +) { val isDuePast = assignment.due != null && assignment.due!! < Date() + val isCompleted = assignment.isAssignmentCompleted(allBlocks) val cardBorderColor = when { isSelected -> MaterialTheme.appColors.primary - assignment.isCompleted() -> MaterialTheme.appColors.successGreen + isCompleted -> MaterialTheme.appColors.successGreen isDuePast -> MaterialTheme.appColors.warning else -> MaterialTheme.appColors.textDark } val icon = when { - assignment.isCompleted() -> painterResource(id = coreR.drawable.ic_core_check) + isCompleted -> painterResource(id = coreR.drawable.ic_core_check) isDuePast -> painterResource(id = coreR.drawable.ic_core_watch_later) else -> null } val iconDescription = when { - assignment.isCompleted() -> stringResource(R.string.course_accessibility_assignment_completed) + isCompleted -> stringResource(R.string.course_accessibility_assignment_completed) isDuePast -> stringResource(R.string.course_accessibility_assignment_completed) else -> null } @@ -334,7 +347,7 @@ private fun AssignmentButton(assignment: Block, isSelected: Boolean, onClick: () else -> 1.dp } val cardBackground = when { - assignment.isCompleted() -> MaterialTheme.appColors.successGreen.copy( + isCompleted -> MaterialTheme.appColors.successGreen.copy( ASSIGNMENT_BUTTON_CARD_BACKGROUND_ALPHA ) @@ -410,6 +423,7 @@ private fun AssignmentButton(assignment: Block, isSelected: Boolean, onClick: () private fun AssignmentDetails( modifier: Modifier = Modifier, assignment: Block, + allBlocks: List, sectionName: String, onAssignmentClick: (Block) -> Unit, ) { @@ -418,15 +432,16 @@ private fun AssignmentDetails( TimeUtils.formatToDueInString(LocalContext.current, it) } ?: "" val isDuePast = assignment.due != null && assignment.due!! < Date() - val progress = assignment.completion.toFloat() + val isCompleted = assignment.isAssignmentCompleted(allBlocks) + val progress = assignment.assignmentCompletion(allBlocks).toFloat() val color = when { - assignment.isCompleted() -> MaterialTheme.appColors.successGreen + isCompleted -> MaterialTheme.appColors.successGreen isDuePast -> MaterialTheme.appColors.warning else -> MaterialTheme.appColors.assignmentCardBorder } val label = assignment.assignmentProgress?.label val description = when { - assignment.isCompleted() -> { + isCompleted -> { "$label " + stringResource( R.string.course_complete_points, assignment.assignmentProgress?.toPointString() ?: "" @@ -529,7 +544,8 @@ private fun CourseContentAssignmentScreenPreview() { "Homework" to listOf(CoreMocks.mockChapterBlock, CourseMocks.sequentialBlock) ), courseProgress = CoreMocks.mockCourseProgress, - sectionNames = mapOf() + sectionNames = mapOf(), + allBlocks = CoreMocks.mockBlockData, ), onAssignmentClick = {}, onNavigateToHome = {}, @@ -563,7 +579,8 @@ private fun CourseContentAssignmentScreenTabletPreview() { "Quiz" to listOf(CourseMocks.sequentialBlock) ), courseProgress = CoreMocks.mockCourseProgress, - sectionNames = mapOf() + sectionNames = mapOf(), + allBlocks = CoreMocks.mockBlockData, ), onAssignmentClick = {}, onNavigateToHome = {}, diff --git a/course/src/main/java/org/openedx/course/presentation/home/AssignmentsHomePagerCardContent.kt b/course/src/main/java/org/openedx/course/presentation/home/AssignmentsHomePagerCardContent.kt index 70e96c0fa..ad5330f05 100644 --- a/course/src/main/java/org/openedx/course/presentation/home/AssignmentsHomePagerCardContent.kt +++ b/course/src/main/java/org/openedx/course/presentation/home/AssignmentsHomePagerCardContent.kt @@ -33,6 +33,7 @@ import androidx.compose.ui.semantics.semantics import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import org.openedx.core.domain.model.Block +import org.openedx.core.domain.model.isAssignmentCompleted import org.openedx.core.ui.theme.appColors import org.openedx.core.ui.theme.appTypography import org.openedx.core.utils.TimeUtils @@ -64,9 +65,21 @@ fun AssignmentsHomePagerCardContent( return } - val completedAssignments = uiState.courseAssignments.count { it.isCompleted() } - val totalAssignments = uiState.courseAssignments.size - val firstIncompleteAssignment = uiState.courseAssignments.find { !it.isCompleted() } + val blockData = uiState.courseStructure.blockData + val lmsProgress = uiState.courseStructure.progress + val completedAssignments = if (lmsProgress != null && lmsProgress.total > 0) { + lmsProgress.completed + } else { + uiState.courseAssignments.count { it.isAssignmentCompleted(blockData) } + } + val totalAssignments = if (lmsProgress != null && lmsProgress.total > 0) { + lmsProgress.total + } else { + uiState.courseAssignments.size + } + val firstIncompleteAssignment = uiState.courseAssignments.find { + !it.isAssignmentCompleted(blockData) + } Column( modifier = Modifier