From 48d6398bfa32ab8c5946a0acd441d36a38fb415b Mon Sep 17 00:00:00 2001 From: PavloNetrebchuk Date: Wed, 18 Jun 2025 20:26:35 +0300 Subject: [PATCH 01/10] feat: progress tab UI --- .../java/org/openedx/app/di/ScreenModule.kt | 7 + .../org/openedx/core/NoContentScreenType.kt | 6 +- .../core/domain/model/CourseProgress.kt | 34 ++ core/src/main/res/values/strings.xml | 1 + .../data/repository/CourseRepository.kt | 46 ++ .../domain/interactor/CourseInteractor.kt | 5 + .../container/CourseContainerFragment.kt | 16 +- .../container/CourseContainerTab.kt | 4 +- .../container/CourseContainerViewModel.kt | 5 + .../progress/CourseProgressScreen.kt | 426 ++++++++++++++++++ .../progress/CourseProgressUIState.kt | 9 + .../progress/CourseProgressViewModel.kt | 43 ++ .../main/res/drawable/course_ic_marker.xml | 12 + course/src/main/res/values/strings.xml | 10 + 14 files changed, 618 insertions(+), 6 deletions(-) create mode 100644 core/src/main/java/org/openedx/core/domain/model/CourseProgress.kt create mode 100644 course/src/main/java/org/openedx/course/presentation/progress/CourseProgressScreen.kt create mode 100644 course/src/main/java/org/openedx/course/presentation/progress/CourseProgressUIState.kt create mode 100644 course/src/main/java/org/openedx/course/presentation/progress/CourseProgressViewModel.kt create mode 100644 course/src/main/res/drawable/course_ic_marker.xml diff --git a/app/src/main/java/org/openedx/app/di/ScreenModule.kt b/app/src/main/java/org/openedx/app/di/ScreenModule.kt index 464007259..6a6667041 100644 --- a/app/src/main/java/org/openedx/app/di/ScreenModule.kt +++ b/app/src/main/java/org/openedx/app/di/ScreenModule.kt @@ -23,6 +23,7 @@ import org.openedx.course.presentation.dates.CourseDatesViewModel import org.openedx.course.presentation.handouts.HandoutsViewModel import org.openedx.course.presentation.offline.CourseOfflineViewModel import org.openedx.course.presentation.outline.CourseOutlineViewModel +import org.openedx.course.presentation.progress.CourseProgressViewModel import org.openedx.course.presentation.section.CourseSectionViewModel import org.openedx.course.presentation.unit.container.CourseUnitContainerViewModel import org.openedx.course.presentation.unit.html.HtmlUnitViewModel @@ -495,6 +496,12 @@ val screenModule = module { get(), ) } + viewModel { (courseId: String) -> + CourseProgressViewModel( + courseId, + get() + ) + } single { DownloadRepository( diff --git a/core/src/main/java/org/openedx/core/NoContentScreenType.kt b/core/src/main/java/org/openedx/core/NoContentScreenType.kt index 88e8ad94b..1b9dcafab 100644 --- a/core/src/main/java/org/openedx/core/NoContentScreenType.kt +++ b/core/src/main/java/org/openedx/core/NoContentScreenType.kt @@ -27,5 +27,9 @@ enum class NoContentScreenType( COURSE_ANNOUNCEMENTS( iconResId = R.drawable.core_ic_no_announcements, messageResId = R.string.core_no_announcements - ) + ), + COURSE_PROGRESS( + iconResId = R.drawable.core_ic_no_content, + messageResId = R.string.core_no_progress + ), } diff --git a/core/src/main/java/org/openedx/core/domain/model/CourseProgress.kt b/core/src/main/java/org/openedx/core/domain/model/CourseProgress.kt new file mode 100644 index 000000000..6a9584c8c --- /dev/null +++ b/core/src/main/java/org/openedx/core/domain/model/CourseProgress.kt @@ -0,0 +1,34 @@ +package org.openedx.core.domain.model + +import androidx.compose.ui.graphics.Color + +data class CourseProgress( + val mfeProctoredExamSettingsUrl: String, + val courseAssignmentLists: Map>, + val courseDetails: CourseDetails, + val showCreditEligibility: Boolean, + val isCreditCourse: Boolean, + val defaultGradeDesignations: List +) { + data class CourseDetails( + val graders: List, + val gradeCutoffs: Map, + val gracePeriod: GracePeriod, + val minimumGradeCredit: Float + ) + + data class Grader( + val type: String, + val minCount: Int, + val dropCount: Int, + val shortLabel: String, + val weight: Int, + val id: String, + val color: Color + ) + + data class GracePeriod( + val hours: Int, + val minutes: Int + ) +} diff --git a/core/src/main/res/values/strings.xml b/core/src/main/res/values/strings.xml index 99df5b3d4..76b702f84 100644 --- a/core/src/main/res/values/strings.xml +++ b/core/src/main/res/values/strings.xml @@ -172,6 +172,7 @@ No course content is currently available. There are currently no videos for this course. Course dates are currently not available. + Course progress are currently not available. Unable to load discussions.\n Please try again later. There are currently no handouts for this course. There are currently no announcements for this course. diff --git a/course/src/main/java/org/openedx/course/data/repository/CourseRepository.kt b/course/src/main/java/org/openedx/course/data/repository/CourseRepository.kt index bf39cc80c..686fd60f0 100644 --- a/course/src/main/java/org/openedx/course/data/repository/CourseRepository.kt +++ b/course/src/main/java/org/openedx/course/data/repository/CourseRepository.kt @@ -1,5 +1,6 @@ package org.openedx.course.data.repository +import androidx.compose.ui.graphics.Color import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.map import okhttp3.MultipartBody @@ -14,6 +15,7 @@ import org.openedx.core.domain.model.CourseComponentStatus import org.openedx.core.domain.model.CourseDatesBannerInfo import org.openedx.core.domain.model.CourseDatesResult import org.openedx.core.domain.model.CourseEnrollmentDetails +import org.openedx.core.domain.model.CourseProgress import org.openedx.core.domain.model.CourseStructure import org.openedx.core.exception.NoCachedDataException import org.openedx.core.extension.channelFlowWithAwait @@ -235,4 +237,48 @@ class CourseRepository( downloadDao.removeOfflineXBlockProgress(listOf(blockId)) } } + + suspend fun getCourseProgress(courseId: String): CourseProgress { + // Mocked response + return CourseProgress( + mfeProctoredExamSettingsUrl = "", + courseAssignmentLists = mapOf( + "Homework" to listOf("Section :754c5e889ac3489e9947ba62b916bdab - Subsection :56c1bc20d270414b877e9c178954b6ed") + ), + courseDetails = CourseProgress.CourseDetails( + graders = listOf( + CourseProgress.Grader( + type = "Homework", + minCount = 1, + dropCount = 0, + shortLabel = "", + weight = 100, + id = "A", + color = Color(0xFFFF9733) + ), + CourseProgress.Grader( + type = "Homework2", + minCount = 1, + dropCount = 0, + shortLabel = "", + weight = 100, + id = "B", + color = Color(0xFFFF0733) + ) + ), + gradeCutoffs = mapOf( + "A" to 0.75f, + "B" to 0.63f, + ), + gracePeriod = CourseProgress.GracePeriod( + hours = 12, + minutes = 0 + ), + minimumGradeCredit = 0.7f + ), + showCreditEligibility = false, + isCreditCourse = true, + defaultGradeDesignations = listOf("A", "B") + ) + } } diff --git a/course/src/main/java/org/openedx/course/domain/interactor/CourseInteractor.kt b/course/src/main/java/org/openedx/course/domain/interactor/CourseInteractor.kt index 8fab7bba7..16bde1da0 100644 --- a/course/src/main/java/org/openedx/course/domain/interactor/CourseInteractor.kt +++ b/course/src/main/java/org/openedx/course/domain/interactor/CourseInteractor.kt @@ -5,6 +5,7 @@ import org.openedx.core.BlockType import org.openedx.core.domain.interactor.CourseInteractor import org.openedx.core.domain.model.Block import org.openedx.core.domain.model.CourseEnrollmentDetails +import org.openedx.core.domain.model.CourseProgress import org.openedx.core.domain.model.CourseStructure import org.openedx.course.data.repository.CourseRepository @@ -114,4 +115,8 @@ class CourseInteractor( suspend fun submitOfflineXBlockProgress(blockId: String, courseId: String) = repository.submitOfflineXBlockProgress(blockId, courseId) + + suspend fun getCourseProgress(courseId: String): CourseProgress { + return repository.getCourseProgress(courseId) + } } diff --git a/course/src/main/java/org/openedx/course/presentation/container/CourseContainerFragment.kt b/course/src/main/java/org/openedx/course/presentation/container/CourseContainerFragment.kt index 1abd8cbb2..352ee4799 100644 --- a/course/src/main/java/org/openedx/course/presentation/container/CourseContainerFragment.kt +++ b/course/src/main/java/org/openedx/course/presentation/container/CourseContainerFragment.kt @@ -88,6 +88,7 @@ import org.openedx.course.presentation.handouts.HandoutsScreen import org.openedx.course.presentation.handouts.HandoutsType import org.openedx.course.presentation.offline.CourseOfflineScreen import org.openedx.course.presentation.outline.CourseOutlineScreen +import org.openedx.course.presentation.progress.CourseProgressScreen import org.openedx.course.presentation.ui.CourseVideosScreen import org.openedx.course.presentation.ui.DatesShiftedSnackBar import org.openedx.discussion.presentation.topics.DiscussionTopicsScreen @@ -267,6 +268,7 @@ fun CourseDashboard( CourseContainerTab.VIDEOS.name -> CourseContainerTab.VIDEOS CourseContainerTab.DATES.name -> CourseContainerTab.DATES CourseContainerTab.DISCUSSIONS.name -> CourseContainerTab.DISCUSSIONS + CourseContainerTab.PROGRESS.name -> CourseContainerTab.PROGRESS CourseContainerTab.MORE.name -> CourseContainerTab.MORE else -> CourseContainerTab.HOME } @@ -344,8 +346,7 @@ fun CourseDashboard( when (accessStatus.value) { CourseAccessError.AUDIT_EXPIRED_NOT_UPGRADABLE, CourseAccessError.NOT_YET_STARTED, - CourseAccessError.UNKNOWN, - -> { + CourseAccessError.UNKNOWN -> { CourseAccessErrorView( viewModel = viewModel, accessError = accessStatus.value, @@ -492,6 +493,14 @@ private fun DashboardPager( ) } + CourseContainerTab.PROGRESS -> { + CourseProgressScreen( + windowSize = windowSize, + viewModel = koinViewModel(parameters = { parametersOf(viewModel.courseId) }), + fragmentManager = fragmentManager + ) + } + CourseContainerTab.MORE -> { HandoutsScreen( windowSize = windowSize, @@ -608,8 +617,7 @@ private fun SetupCourseAccessErrorButtons( ) { when (accessError) { CourseAccessError.AUDIT_EXPIRED_NOT_UPGRADABLE, - CourseAccessError.NOT_YET_STARTED, - -> { + CourseAccessError.NOT_YET_STARTED -> { OpenEdXButton( text = stringResource(R.string.course_label_back), onClick = { fragmentManager.popBackStack() }, diff --git a/course/src/main/java/org/openedx/course/presentation/container/CourseContainerTab.kt b/course/src/main/java/org/openedx/course/presentation/container/CourseContainerTab.kt index b591c7ecf..236c548f6 100644 --- a/course/src/main/java/org/openedx/course/presentation/container/CourseContainerTab.kt +++ b/course/src/main/java/org/openedx/course/presentation/container/CourseContainerTab.kt @@ -6,6 +6,7 @@ import androidx.compose.material.icons.automirrored.filled.Chat import androidx.compose.material.icons.automirrored.filled.TextSnippet import androidx.compose.material.icons.filled.CloudDownload import androidx.compose.material.icons.filled.Home +import androidx.compose.material.icons.filled.Moving import androidx.compose.material.icons.outlined.CalendarMonth import androidx.compose.material.icons.rounded.PlayCircleFilled import androidx.compose.ui.graphics.vector.ImageVector @@ -19,8 +20,9 @@ enum class CourseContainerTab( ) : TabItem { HOME(R.string.course_container_nav_home, Icons.Default.Home), VIDEOS(R.string.course_container_nav_videos, Icons.Rounded.PlayCircleFilled), + PROGRESS(R.string.course_container_nav_progress, Icons.Default.Moving), DATES(R.string.course_container_nav_dates, Icons.Outlined.CalendarMonth), OFFLINE(R.string.course_container_nav_downloads, Icons.Filled.CloudDownload), DISCUSSIONS(R.string.course_container_nav_discussions, Icons.AutoMirrored.Filled.Chat), - MORE(R.string.course_container_nav_more, Icons.AutoMirrored.Filled.TextSnippet) + MORE(R.string.course_container_nav_more, Icons.AutoMirrored.Filled.TextSnippet), } diff --git a/course/src/main/java/org/openedx/course/presentation/container/CourseContainerViewModel.kt b/course/src/main/java/org/openedx/course/presentation/container/CourseContainerViewModel.kt index f3d2bd2c7..d41bc55ea 100644 --- a/course/src/main/java/org/openedx/course/presentation/container/CourseContainerViewModel.kt +++ b/course/src/main/java/org/openedx/course/presentation/container/CourseContainerViewModel.kt @@ -303,6 +303,10 @@ class CourseContainerViewModel( } } + CourseContainerTab.PROGRESS -> { + _refreshing.value = false + } + else -> { _refreshing.value = false } @@ -328,6 +332,7 @@ class CourseContainerViewModel( CourseContainerTab.VIDEOS -> videoTabClickedEvent() CourseContainerTab.DISCUSSIONS -> discussionTabClickedEvent() CourseContainerTab.DATES -> datesTabClickedEvent() + CourseContainerTab.PROGRESS -> {} CourseContainerTab.MORE -> moreTabClickedEvent() CourseContainerTab.OFFLINE -> {} } diff --git a/course/src/main/java/org/openedx/course/presentation/progress/CourseProgressScreen.kt b/course/src/main/java/org/openedx/course/presentation/progress/CourseProgressScreen.kt new file mode 100644 index 000000000..5d0a998d4 --- /dev/null +++ b/course/src/main/java/org/openedx/course/presentation/progress/CourseProgressScreen.kt @@ -0,0 +1,426 @@ +package org.openedx.course.presentation.progress + +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.IntrinsicSize +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.CircularProgressIndicator +import androidx.compose.material.Icon +import androidx.compose.material.MaterialTheme +import androidx.compose.material.Scaffold +import androidx.compose.material.Surface +import androidx.compose.material.Text +import androidx.compose.material.rememberScaffoldState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +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.graphics.StrokeCap +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import androidx.fragment.app.FragmentManager +import org.openedx.core.NoContentScreenType +import org.openedx.core.domain.model.CourseProgress +import org.openedx.core.ui.CircularProgress +import org.openedx.core.ui.HandleUIMessage +import org.openedx.core.ui.NoContentScreen +import org.openedx.core.ui.displayCutoutForLandscape +import org.openedx.core.ui.theme.appColors +import org.openedx.core.ui.theme.appShapes +import org.openedx.core.ui.theme.appTypography +import org.openedx.course.R +import org.openedx.foundation.presentation.UIMessage +import org.openedx.foundation.presentation.WindowSize +import org.openedx.foundation.presentation.windowSizeValue + +@Composable +fun CourseProgressScreen( + windowSize: WindowSize, + viewModel: CourseProgressViewModel, + fragmentManager: FragmentManager, +) { + val uiState by viewModel.uiState.collectAsState() + val uiMessage by viewModel.uiMessage.collectAsState(null) + + when (val state = uiState) { + is CourseProgressUIState.Loading -> CircularProgress() + is CourseProgressUIState.Error -> NoContentScreen(NoContentScreenType.COURSE_PROGRESS) + is CourseProgressUIState.Data -> CourseProgressContent( + uiState = state, + uiMessage = uiMessage, + windowSize = windowSize, + ) + } +} + +@Composable +private fun CourseProgressContent( + uiState: CourseProgressUIState.Data, + uiMessage: UIMessage?, + windowSize: WindowSize +) { + // Mocked values for demo, replace with real data from progress + val completionPercent = 0.5f // 50% completed + val overallGrade = 0.52f // 52% + val gradeCutoff = uiState.progress.courseDetails.gradeCutoffs["A"] ?: 0.75f + val requiredGradePercent = (gradeCutoff * 100).toInt() + val currentGradePercent = (overallGrade * 100).toInt() + + val scaffoldState = rememberScaffoldState() + + Scaffold( + modifier = Modifier + .fillMaxSize(), + scaffoldState = scaffoldState, + backgroundColor = MaterialTheme.appColors.background + ) { + val screenWidth by remember(key1 = windowSize) { + mutableStateOf( + windowSize.windowSizeValue( + expanded = Modifier.widthIn(Dp.Unspecified, 560.dp), + compact = Modifier.fillMaxWidth() + ) + ) + } + + HandleUIMessage(uiMessage = uiMessage, scaffoldState = scaffoldState) + + Box( + modifier = Modifier + .fillMaxSize() + .padding(it) + .verticalScroll(rememberScrollState()) + .displayCutoutForLandscape(), + contentAlignment = Alignment.TopCenter + ) { + Surface( + modifier = screenWidth, + color = MaterialTheme.appColors.background + ) { + Box { + Column( + modifier = Modifier + .fillMaxSize() + .padding(16.dp), + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + CourseCompletionView(completionPercent) + OverallGradeView( + courseProgress = uiState.progress, + currentGradePercent = currentGradePercent, + overallGrade = overallGrade, + gradeCutoff = gradeCutoff, + requiredGradePercent = requiredGradePercent + ) + GradeDetailsView(uiState.progress) + } + } + } + } + } +} + +@Composable +private fun GradeDetailsView( + courseProgress: CourseProgress, +) { + Text( + text = stringResource(R.string.course_progress_grade_details), + style = MaterialTheme.appTypography.titleMedium, + color = MaterialTheme.appColors.textDark, + ) + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween + ) { + Text( + text = stringResource(R.string.course_progress_assignment_type), + style = MaterialTheme.appTypography.bodySmall, + color = MaterialTheme.appColors.textPrimaryVariant, + ) + Text( + text = stringResource(R.string.course_progress_current_max), + style = MaterialTheme.appTypography.bodySmall, + color = MaterialTheme.appColors.textPrimaryVariant, + ) + } + courseProgress.courseDetails.graders.forEach { grader -> + AssignmentTypeRow(grader) + } + Spacer(modifier = Modifier.height(8.dp)) + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween + ) { + Text( + text = stringResource(R.string.course_progress_current_overall), + style = MaterialTheme.appTypography.labelLarge, + color = MaterialTheme.appColors.textDark, + ) + Text( + text = "777%", + style = MaterialTheme.appTypography.labelLarge, + color = MaterialTheme.appColors.primary, + fontWeight = FontWeight.SemiBold + ) + } +} + +@Composable +private fun OverallGradeView( + courseProgress: CourseProgress, + currentGradePercent: Int, + overallGrade: Float, + gradeCutoff: Float, + requiredGradePercent: Int, +) { + Text( + text = stringResource(R.string.course_progress_overall_title), + style = MaterialTheme.appTypography.titleMedium, + color = MaterialTheme.appColors.textDark, + ) + Text( + text = stringResource(R.string.course_progress_overall_description), + style = MaterialTheme.appTypography.labelMedium, + color = MaterialTheme.appColors.textDark, + ) + Row( + verticalAlignment = Alignment.Bottom, + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + Text( + text = stringResource(R.string.course_progress_current_overall), + style = MaterialTheme.appTypography.labelMedium, + color = MaterialTheme.appColors.textDark, + ) + Text( + text = "${currentGradePercent}%", + style = MaterialTheme.appTypography.labelMedium, + color = MaterialTheme.appColors.primary, + fontWeight = FontWeight.SemiBold + ) + } + + Column { + val weightSum = courseProgress.courseDetails.gradeCutoffs.values.sum() + val weightMax = courseProgress.courseDetails.gradeCutoffs.size + val left = weightMax - weightSum + Row( + modifier = Modifier + .fillMaxWidth() + .height(8.dp) + .clip(CircleShape) + .border( + width = 1.dp, + color = MaterialTheme.appColors.cardViewBackground, + shape = CircleShape + ) + ) { + courseProgress.courseDetails.gradeCutoffs.toList().forEach { grade -> + val color = + courseProgress.courseDetails.graders.find { it.id == grade.first }?.color + ?: Color.Transparent + Box( + modifier = Modifier + .weight(grade.second) + .background(color) + .fillMaxHeight() + ) + } + Box( + modifier = Modifier + .weight(left) + .fillMaxHeight() + ) + } + if (left > 0f) { + Box( + modifier = Modifier + .fillMaxWidth((weightSum + left / 2) / weightMax), + contentAlignment = Alignment.CenterEnd + ) { + Box( + contentAlignment = Alignment.Center + ) { + Icon( + painter = painterResource(id = R.drawable.course_ic_marker), + tint = MaterialTheme.appColors.warning, + contentDescription = null + ) + Text( + modifier = Modifier.offset(y = 2.dp), + text = "$requiredGradePercent%", + style = MaterialTheme.appTypography.labelMedium, + color = MaterialTheme.appColors.textDark, + ) + } + } + } + } + + Surface( + color = MaterialTheme.appColors.cardViewBackground, + shape = MaterialTheme.appShapes.cardShape, + border = BorderStroke( + width = 1.dp, + color = MaterialTheme.appColors.warning + ), + modifier = Modifier.fillMaxWidth() + ) { + Row( + modifier = Modifier.padding(vertical = 12.dp, horizontal = 16.dp), + ) { + Icon( + modifier = Modifier.size(16.dp), + painter = painterResource(id = android.R.drawable.ic_dialog_alert), + contentDescription = null, + tint = MaterialTheme.appColors.warning, + ) + Spacer(modifier = Modifier.width(8.dp)) + Text( + text = stringResource( + R.string.course_progress_required_grade_percent, + requiredGradePercent + ), + style = MaterialTheme.appTypography.labelLarge, + color = MaterialTheme.appColors.textDark, + ) + } + } +} + +@Composable +private fun CourseCompletionView( + completionPercent: Float +) { + Row( + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + Column( + modifier = Modifier.weight(1f), + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { + Text( + text = stringResource(R.string.course_progress_completion_title), + style = MaterialTheme.appTypography.titleMedium, + color = MaterialTheme.appColors.textDark, + ) + Text( + text = stringResource(R.string.course_progress_completion_description), + style = MaterialTheme.appTypography.labelMedium, + color = MaterialTheme.appColors.textDark, + ) + } + Box( + modifier = Modifier + .align(Alignment.CenterVertically) + ) { + CircularProgressIndicator( + modifier = Modifier + .size(100.dp) + .border( + width = 1.dp, + color = MaterialTheme.appColors.progressBarBackgroundColor, + shape = CircleShape + ) + .padding(3.dp), + progress = completionPercent, + color = MaterialTheme.appColors.progressBarColor, + backgroundColor = MaterialTheme.appColors.progressBarBackgroundColor, + strokeWidth = 10.dp, + strokeCap = StrokeCap.Round + ) + Column( + modifier = Modifier.align(Alignment.Center), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Text( + text = "${(completionPercent * 100).toInt()}%", + style = MaterialTheme.appTypography.headlineSmall, + color = MaterialTheme.appColors.primary, + ) + Text( + text = "Status", + style = MaterialTheme.appTypography.labelSmall, + color = MaterialTheme.appColors.textPrimaryVariant, + ) + } + } + } +} + +@Composable +private fun AssignmentTypeRow(grader: CourseProgress.Grader) { + Column { + Text( + text = grader.type, + style = MaterialTheme.appTypography.labelLarge, + color = grader.color, + ) + Row( + Modifier + .fillMaxWidth() + .height(IntrinsicSize.Min) + .padding(vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Box( + modifier = Modifier + .fillMaxHeight() + .width(7.dp) + .background( + color = grader.color, + shape = CircleShape + ) + ) + Spacer(modifier = Modifier.width(8.dp)) + Column( + modifier = Modifier.weight(1f) + ) { + Text( + text = "13 / 16 Complete", + style = MaterialTheme.appTypography.bodySmall, + color = MaterialTheme.appColors.textDark, + ) + Text( + text = "${grader.weight}% of Grade", + style = MaterialTheme.appTypography.bodySmall, + color = MaterialTheme.appColors.textDark, + ) + } + Text( + "10 / 15%", + style = MaterialTheme.appTypography.bodyLarge, + fontWeight = FontWeight.W700, + color = MaterialTheme.appColors.textDark, + ) + } + } +} diff --git a/course/src/main/java/org/openedx/course/presentation/progress/CourseProgressUIState.kt b/course/src/main/java/org/openedx/course/presentation/progress/CourseProgressUIState.kt new file mode 100644 index 000000000..25771f631 --- /dev/null +++ b/course/src/main/java/org/openedx/course/presentation/progress/CourseProgressUIState.kt @@ -0,0 +1,9 @@ +package org.openedx.course.presentation.progress + +import org.openedx.core.domain.model.CourseProgress + +sealed class CourseProgressUIState { + data object Error : CourseProgressUIState() + data object Loading : CourseProgressUIState() + data class Data(val progress: CourseProgress) : CourseProgressUIState() +} diff --git a/course/src/main/java/org/openedx/course/presentation/progress/CourseProgressViewModel.kt b/course/src/main/java/org/openedx/course/presentation/progress/CourseProgressViewModel.kt new file mode 100644 index 000000000..f8dce044d --- /dev/null +++ b/course/src/main/java/org/openedx/course/presentation/progress/CourseProgressViewModel.kt @@ -0,0 +1,43 @@ +package org.openedx.course.presentation.progress + +import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch +import org.openedx.course.domain.interactor.CourseInteractor +import org.openedx.foundation.presentation.BaseViewModel +import org.openedx.foundation.presentation.UIMessage + +class CourseProgressViewModel( + val courseId: String, + private val interactor: CourseInteractor, +) : BaseViewModel() { + + private val _uiState = MutableStateFlow(CourseProgressUIState.Loading) + val uiState: StateFlow + get() = _uiState.asStateFlow() + + private val _uiMessage = MutableSharedFlow() + val uiMessage: SharedFlow + get() = _uiMessage.asSharedFlow() + + init { + loadCourseProgress() + } + + fun loadCourseProgress() { + viewModelScope.launch { + _uiState.value = CourseProgressUIState.Loading + try { + val progress = interactor.getCourseProgress(courseId) + _uiState.value = CourseProgressUIState.Data(progress) + } catch (e: Exception) { + _uiState.value = CourseProgressUIState.Error + } + } + } +} diff --git a/course/src/main/res/drawable/course_ic_marker.xml b/course/src/main/res/drawable/course_ic_marker.xml new file mode 100644 index 000000000..007f3425b --- /dev/null +++ b/course/src/main/res/drawable/course_ic_marker.xml @@ -0,0 +1,12 @@ + + + + diff --git a/course/src/main/res/values/strings.xml b/course/src/main/res/values/strings.xml index e4ae9e39d..c4ef714ee 100644 --- a/course/src/main/res/values/strings.xml +++ b/course/src/main/res/values/strings.xml @@ -36,6 +36,15 @@ Some content in this part of the course is locked for upgraded users only. You cannot change the download video quality when all videos are downloading Dates Shifted + Course Completion + This represents how much of the course content you have completed. Note that some content may not yet be released. + Overall Grade + This represents your weighted grade against the grade needed to pass this course. + Current Overall Weighted Grade: + A weighted grade of %1$d%% is required to pass this course + Grade Details + ASSIGNMENT TYPE + CURRENT - MAX Course dates are not currently available. @@ -46,6 +55,7 @@ More Dates Downloads + Progress Video player From ec59fb44591092cb1c6ca9d038e7be908705d4a8 Mon Sep 17 00:00:00 2001 From: PavloNetrebchuk Date: Mon, 23 Jun 2025 14:59:53 +0300 Subject: [PATCH 02/10] feat: progress tab logic --- .../java/org/openedx/app/di/ScreenModule.kt | 1 + .../org/openedx/core/data/api/CourseApi.kt | 7 + .../core/data/model/CourseProgressResponse.kt | 162 +++++++++ .../core/domain/model/CourseProgress.kt | 145 ++++++-- .../core/system/notifier/CourseNotifier.kt | 1 + .../core/system/notifier/RefreshProgress.kt | 3 + core/src/main/res/values/strings.xml | 2 +- .../data/repository/CourseRepository.kt | 44 +-- .../container/CourseContainerFragment.kt | 1 - .../container/CourseContainerViewModel.kt | 7 +- .../progress/CourseProgressScreen.kt | 322 ++++++++++-------- .../progress/CourseProgressViewModel.kt | 18 +- course/src/main/res/values/strings.xml | 8 +- 13 files changed, 504 insertions(+), 217 deletions(-) create mode 100644 core/src/main/java/org/openedx/core/data/model/CourseProgressResponse.kt create mode 100644 core/src/main/java/org/openedx/core/system/notifier/RefreshProgress.kt diff --git a/app/src/main/java/org/openedx/app/di/ScreenModule.kt b/app/src/main/java/org/openedx/app/di/ScreenModule.kt index 6a6667041..7f016ace9 100644 --- a/app/src/main/java/org/openedx/app/di/ScreenModule.kt +++ b/app/src/main/java/org/openedx/app/di/ScreenModule.kt @@ -499,6 +499,7 @@ val screenModule = module { viewModel { (courseId: String) -> CourseProgressViewModel( courseId, + get(), get() ) } 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 50cd81d6b..e895d247b 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 @@ -8,6 +8,7 @@ import org.openedx.core.data.model.CourseDates import org.openedx.core.data.model.CourseDatesBannerInfo import org.openedx.core.data.model.CourseEnrollmentDetails import org.openedx.core.data.model.CourseEnrollments +import org.openedx.core.data.model.CourseProgressResponse import org.openedx.core.data.model.CourseStructureModel import org.openedx.core.data.model.DownloadCoursePreview import org.openedx.core.data.model.EnrollmentStatus @@ -106,4 +107,10 @@ interface CourseApi { suspend fun getDownloadCoursesPreview( @Path("username") username: String ): List + + + @GET("/api/course_home/progress/{course_id}") + suspend fun getCourseProgress( + @Path("course_id") courseId: String, + ): CourseProgressResponse } diff --git a/core/src/main/java/org/openedx/core/data/model/CourseProgressResponse.kt b/core/src/main/java/org/openedx/core/data/model/CourseProgressResponse.kt new file mode 100644 index 000000000..f4c784443 --- /dev/null +++ b/core/src/main/java/org/openedx/core/data/model/CourseProgressResponse.kt @@ -0,0 +1,162 @@ +package org.openedx.core.data.model + +import com.google.gson.annotations.SerializedName +import org.openedx.core.domain.model.CourseProgress + +data class CourseProgressResponse( + @SerializedName("verified_mode") val verifiedMode: String?, + @SerializedName("access_expiration") val accessExpiration: String?, + @SerializedName("certificate_data") val certificateData: CertificateData?, + @SerializedName("completion_summary") val completionSummary: CompletionSummary?, + @SerializedName("course_grade") val courseGrade: CourseGrade?, + @SerializedName("credit_course_requirements") val creditCourseRequirements: String?, + @SerializedName("end") val end: String?, + @SerializedName("enrollment_mode") val enrollmentMode: String?, + @SerializedName("grading_policy") val gradingPolicy: GradingPolicy?, + @SerializedName("has_scheduled_content") val hasScheduledContent: Boolean?, + @SerializedName("section_scores") val sectionScores: List?, + @SerializedName("studio_url") val studioUrl: String?, + @SerializedName("username") val username: String?, + @SerializedName("user_has_passing_grade") val userHasPassingGrade: Boolean?, + @SerializedName("verification_data") val verificationData: VerificationData?, + @SerializedName("disable_progress_graph") val disableProgressGraph: Boolean? +) { + data class CertificateData( + @SerializedName("cert_status") val certStatus: String?, + @SerializedName("cert_web_view_url") val certWebViewUrl: String?, + @SerializedName("download_url") val downloadUrl: String?, + @SerializedName("certificate_available_date") val certificateAvailableDate: String? + ) + + data class CompletionSummary( + @SerializedName("complete_count") val completeCount: Int?, + @SerializedName("incomplete_count") val incompleteCount: Int?, + @SerializedName("locked_count") val lockedCount: Int? + ) + + data class CourseGrade( + @SerializedName("letter_grade") val letterGrade: String?, + @SerializedName("percent") val percent: Double?, + @SerializedName("is_passing") val isPassing: Boolean? + ) + + data class GradingPolicy( + @SerializedName("assignment_policies") val assignmentPolicies: List?, + @SerializedName("grade_range") val gradeRange: Map? + ) { + data class AssignmentPolicy( + @SerializedName("num_droppable") val numDroppable: Int?, + @SerializedName("num_total") val numTotal: Int?, + @SerializedName("short_label") val shortLabel: String?, + @SerializedName("type") val type: String?, + @SerializedName("weight") val weight: Double? + ) + } + + data class SectionScore( + @SerializedName("display_name") val displayName: String?, + @SerializedName("subsections") val subsections: List? + ) { + data class Subsection( + @SerializedName("assignment_type") val assignmentType: String?, + @SerializedName("block_key") val blockKey: String?, + @SerializedName("display_name") val displayName: String?, + @SerializedName("has_graded_assignment") val hasGradedAssignment: Boolean?, + @SerializedName("override") val override: String?, + @SerializedName("learner_has_access") val learnerHasAccess: Boolean?, + @SerializedName("num_points_earned") val numPointsEarned: Int?, + @SerializedName("num_points_possible") val numPointsPossible: Int?, + @SerializedName("percent_graded") val percentGraded: Double?, + @SerializedName("problem_scores") val problemScores: List?, + @SerializedName("show_correctness") val showCorrectness: String?, + @SerializedName("show_grades") val showGrades: Boolean?, + @SerializedName("url") val url: String? + ) { + data class ProblemScore( + @SerializedName("earned") val earned: Int?, + @SerializedName("possible") val possible: Int? + ) + } + } + + data class VerificationData( + @SerializedName("link") val link: String?, + @SerializedName("status") val status: String?, + @SerializedName("status_date") val statusDate: String? + ) + + fun mapToDomain(): CourseProgress { + return CourseProgress( + verifiedMode = verifiedMode ?: "", + accessExpiration = accessExpiration ?: "", + certificateData = CourseProgress.CertificateData( + certStatus = certificateData?.certStatus ?: "", + certWebViewUrl = certificateData?.certWebViewUrl ?: "", + downloadUrl = certificateData?.downloadUrl ?: "", + certificateAvailableDate = certificateData?.certificateAvailableDate ?: "" + ), + completionSummary = CourseProgress.CompletionSummary( + completeCount = completionSummary?.completeCount ?: 0, + incompleteCount = completionSummary?.incompleteCount ?: 0, + lockedCount = completionSummary?.lockedCount ?: 0 + ), + courseGrade = CourseProgress.CourseGrade( + letterGrade = courseGrade?.letterGrade ?: "", + percent = courseGrade?.percent ?: 0.0, + isPassing = courseGrade?.isPassing ?: false + ), + creditCourseRequirements = creditCourseRequirements ?: "", + end = end ?: "", + enrollmentMode = enrollmentMode ?: "", + gradingPolicy = CourseProgress.GradingPolicy( + assignmentPolicies = gradingPolicy?.assignmentPolicies?.map { + CourseProgress.GradingPolicy.AssignmentPolicy( + numDroppable = it.numDroppable ?: 0, + numTotal = it.numTotal ?: 0, + shortLabel = it.shortLabel ?: "", + type = it.type ?: "", + weight = it.weight ?: 0.0 + ) + } ?: emptyList(), + gradeRange = gradingPolicy?.gradeRange ?: emptyMap() + ), + hasScheduledContent = hasScheduledContent ?: false, + sectionScores = sectionScores?.map { section -> + CourseProgress.SectionScore( + displayName = section.displayName ?: "", + subsections = section.subsections?.map { subsection -> + CourseProgress.SectionScore.Subsection( + assignmentType = subsection.assignmentType ?: "", + blockKey = subsection.blockKey ?: "", + displayName = subsection.displayName ?: "", + hasGradedAssignment = subsection.hasGradedAssignment ?: false, + override = subsection.override ?: "", + learnerHasAccess = subsection.learnerHasAccess ?: false, + numPointsEarned = subsection.numPointsEarned ?: 0, + numPointsPossible = subsection.numPointsPossible ?: 0, + percentGraded = subsection.percentGraded ?: 0.0, + problemScores = subsection.problemScores?.map { problemScore -> + CourseProgress.SectionScore.Subsection.ProblemScore( + earned = problemScore.earned ?: 0, + possible = problemScore.possible ?: 0 + ) + } ?: emptyList(), + showCorrectness = subsection.showCorrectness ?: "", + showGrades = subsection.showGrades ?: false, + url = subsection.url ?: "" + ) + } ?: emptyList() + ) + } ?: emptyList(), + studioUrl = studioUrl ?: "", + username = username ?: "", + userHasPassingGrade = userHasPassingGrade ?: false, + verificationData = CourseProgress.VerificationData( + link = verificationData?.link ?: "", + status = verificationData?.status ?: "", + statusDate = verificationData?.statusDate ?: "" + ), + disableProgressGraph = disableProgressGraph ?: false + ) + } +} diff --git a/core/src/main/java/org/openedx/core/domain/model/CourseProgress.kt b/core/src/main/java/org/openedx/core/domain/model/CourseProgress.kt index 6a9584c8c..2e5358a4a 100644 --- a/core/src/main/java/org/openedx/core/domain/model/CourseProgress.kt +++ b/core/src/main/java/org/openedx/core/domain/model/CourseProgress.kt @@ -1,34 +1,131 @@ package org.openedx.core.domain.model -import androidx.compose.ui.graphics.Color - data class CourseProgress( - val mfeProctoredExamSettingsUrl: String, - val courseAssignmentLists: Map>, - val courseDetails: CourseDetails, - val showCreditEligibility: Boolean, - val isCreditCourse: Boolean, - val defaultGradeDesignations: List + val verifiedMode: String, + val accessExpiration: String, + val certificateData: CertificateData, + val completionSummary: CompletionSummary, + val courseGrade: CourseGrade, + val creditCourseRequirements: String, + val end: String, + val enrollmentMode: String, + val gradingPolicy: GradingPolicy, + val hasScheduledContent: Boolean, + val sectionScores: List, + val studioUrl: String, + val username: String, + val userHasPassingGrade: Boolean, + val verificationData: VerificationData, + val disableProgressGraph: Boolean, ) { - data class CourseDetails( - val graders: List, - val gradeCutoffs: Map, - val gracePeriod: GracePeriod, - val minimumGradeCredit: Float + val completion = with(completionSummary) { + val total = completeCount + incompleteCount + if (total > 0f) completeCount.toFloat() / total else 0f + } + val completionPercent = (completion * 100f).toInt() + val currentGrade = courseGrade.percent + val currentGradePercent = (currentGrade * 100f).toInt() // ??? + val requiredGrade = gradingPolicy.gradeRange.values.firstOrNull() ?: 0f + val requiredGradePercent = (requiredGrade * 100f).toInt() + + fun getEarnedAssignmentProblems( + policy: GradingPolicy.AssignmentPolicy + ) = sectionScores + .flatMap { section -> + section.subsections.filter { it.assignmentType == policy.type } + }.sumOf { subsection -> + subsection.problemScores.sumOf { it.earned } + } + + fun getPossibleAssignmentProblems( + policy: GradingPolicy.AssignmentPolicy + ) = sectionScores + .flatMap { section -> + section.subsections.filter { it.assignmentType == policy.type } + }.sumOf { subsection -> + subsection.problemScores.sumOf { it.possible } + } + + fun getAssignmentGradedPercent(type: String) = sectionScores + .flatMap { it.subsections } + .filter { it.assignmentType == type } + .sumOf { it.percentGraded } + + fun getAssignmentWeightedGradedPercent(assignmentPolicy: GradingPolicy.AssignmentPolicy): Float { + return (assignmentPolicy.weight * getAssignmentGradedPercent(assignmentPolicy.type) * 100.0).toFloat() + } + + fun getTotalWeightPercent() = + gradingPolicy.assignmentPolicies.sumOf { getAssignmentWeightedGradedPercent(it).toDouble() } + .toFloat() + + fun getNotCompletedWeightedGradePercent(): Float { + val totalWeightedPercent = getTotalWeightPercent() + val notCompletedPercent = 100.0 - totalWeightedPercent + return if (notCompletedPercent < 0.0) 0f else notCompletedPercent.toFloat() + } + + data class CertificateData( + val certStatus: String, + val certWebViewUrl: String, + val downloadUrl: String, + val certificateAvailableDate: String + ) + + data class CompletionSummary( + val completeCount: Int, + val incompleteCount: Int, + val lockedCount: Int ) - data class Grader( - val type: String, - val minCount: Int, - val dropCount: Int, - val shortLabel: String, - val weight: Int, - val id: String, - val color: Color + data class CourseGrade( + val letterGrade: String, + val percent: Double, + val isPassing: Boolean ) - data class GracePeriod( - val hours: Int, - val minutes: Int + data class GradingPolicy( + val assignmentPolicies: List, + val gradeRange: Map + ) { + data class AssignmentPolicy( + val numDroppable: Int, + val numTotal: Int, + val shortLabel: String, + val type: String, + val weight: Double + ) + } + + data class SectionScore( + val displayName: String, + val subsections: List + ) { + data class Subsection( + val assignmentType: String, + val blockKey: String, + val displayName: String, + val hasGradedAssignment: Boolean, + val override: String, + val learnerHasAccess: Boolean, + val numPointsEarned: Int, + val numPointsPossible: Int, + val percentGraded: Double, + val problemScores: List, + val showCorrectness: String, + val showGrades: Boolean, + val url: String + ) { + data class ProblemScore( + val earned: Int, + val possible: Int + ) + } + } + + data class VerificationData( + val link: String, + val status: String, + val statusDate: String ) } diff --git a/core/src/main/java/org/openedx/core/system/notifier/CourseNotifier.kt b/core/src/main/java/org/openedx/core/system/notifier/CourseNotifier.kt index be653a3ed..d3dac7d42 100644 --- a/core/src/main/java/org/openedx/core/system/notifier/CourseNotifier.kt +++ b/core/src/main/java/org/openedx/core/system/notifier/CourseNotifier.kt @@ -22,4 +22,5 @@ class CourseNotifier { suspend fun send(event: CourseOpenBlock) = channel.emit(event) suspend fun send(event: RefreshDates) = channel.emit(event) suspend fun send(event: RefreshDiscussions) = channel.emit(event) + suspend fun send(event: RefreshProgress) = channel.emit(event) } diff --git a/core/src/main/java/org/openedx/core/system/notifier/RefreshProgress.kt b/core/src/main/java/org/openedx/core/system/notifier/RefreshProgress.kt new file mode 100644 index 000000000..c0835f787 --- /dev/null +++ b/core/src/main/java/org/openedx/core/system/notifier/RefreshProgress.kt @@ -0,0 +1,3 @@ +package org.openedx.core.system.notifier + +object RefreshProgress : CourseEvent diff --git a/core/src/main/res/values/strings.xml b/core/src/main/res/values/strings.xml index 76b702f84..ae731550f 100644 --- a/core/src/main/res/values/strings.xml +++ b/core/src/main/res/values/strings.xml @@ -172,7 +172,7 @@ No course content is currently available. There are currently no videos for this course. Course dates are currently not available. - Course progress are currently not available. + This course does not contain exams or graded assignments. Unable to load discussions.\n Please try again later. There are currently no handouts for this course. There are currently no announcements for this course. diff --git a/course/src/main/java/org/openedx/course/data/repository/CourseRepository.kt b/course/src/main/java/org/openedx/course/data/repository/CourseRepository.kt index 686fd60f0..ec824f561 100644 --- a/course/src/main/java/org/openedx/course/data/repository/CourseRepository.kt +++ b/course/src/main/java/org/openedx/course/data/repository/CourseRepository.kt @@ -1,6 +1,5 @@ package org.openedx.course.data.repository -import androidx.compose.ui.graphics.Color import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.map import okhttp3.MultipartBody @@ -239,46 +238,7 @@ class CourseRepository( } suspend fun getCourseProgress(courseId: String): CourseProgress { - // Mocked response - return CourseProgress( - mfeProctoredExamSettingsUrl = "", - courseAssignmentLists = mapOf( - "Homework" to listOf("Section :754c5e889ac3489e9947ba62b916bdab - Subsection :56c1bc20d270414b877e9c178954b6ed") - ), - courseDetails = CourseProgress.CourseDetails( - graders = listOf( - CourseProgress.Grader( - type = "Homework", - minCount = 1, - dropCount = 0, - shortLabel = "", - weight = 100, - id = "A", - color = Color(0xFFFF9733) - ), - CourseProgress.Grader( - type = "Homework2", - minCount = 1, - dropCount = 0, - shortLabel = "", - weight = 100, - id = "B", - color = Color(0xFFFF0733) - ) - ), - gradeCutoffs = mapOf( - "A" to 0.75f, - "B" to 0.63f, - ), - gracePeriod = CourseProgress.GracePeriod( - hours = 12, - minutes = 0 - ), - minimumGradeCredit = 0.7f - ), - showCreditEligibility = false, - isCreditCourse = true, - defaultGradeDesignations = listOf("A", "B") - ) + val response = api.getCourseProgress(courseId) + return response.mapToDomain() } } diff --git a/course/src/main/java/org/openedx/course/presentation/container/CourseContainerFragment.kt b/course/src/main/java/org/openedx/course/presentation/container/CourseContainerFragment.kt index 352ee4799..a71d954df 100644 --- a/course/src/main/java/org/openedx/course/presentation/container/CourseContainerFragment.kt +++ b/course/src/main/java/org/openedx/course/presentation/container/CourseContainerFragment.kt @@ -497,7 +497,6 @@ private fun DashboardPager( CourseProgressScreen( windowSize = windowSize, viewModel = koinViewModel(parameters = { parametersOf(viewModel.courseId) }), - fragmentManager = fragmentManager ) } diff --git a/course/src/main/java/org/openedx/course/presentation/container/CourseContainerViewModel.kt b/course/src/main/java/org/openedx/course/presentation/container/CourseContainerViewModel.kt index d41bc55ea..88c27e2c8 100644 --- a/course/src/main/java/org/openedx/course/presentation/container/CourseContainerViewModel.kt +++ b/course/src/main/java/org/openedx/course/presentation/container/CourseContainerViewModel.kt @@ -40,6 +40,7 @@ import org.openedx.core.system.notifier.CourseStructureGot import org.openedx.core.system.notifier.CourseStructureUpdated import org.openedx.core.system.notifier.RefreshDates import org.openedx.core.system.notifier.RefreshDiscussions +import org.openedx.core.system.notifier.RefreshProgress import org.openedx.core.worker.CalendarSyncScheduler import org.openedx.course.DatesShiftedSnackBar import org.openedx.course.domain.interactor.CourseInteractor @@ -304,7 +305,9 @@ class CourseContainerViewModel( } CourseContainerTab.PROGRESS -> { - _refreshing.value = false + viewModelScope.launch { + courseNotifier.send(RefreshProgress) + } } else -> { @@ -317,7 +320,7 @@ class CourseContainerViewModel( viewModelScope.launch { try { interactor.getCourseStructure(courseId, isNeedRefresh = true) - } catch (ignore: Exception) { + } catch (_: Exception) { _errorMessage.value = resourceManager.getString(CoreR.string.core_error_unknown_error) } diff --git a/course/src/main/java/org/openedx/course/presentation/progress/CourseProgressScreen.kt b/course/src/main/java/org/openedx/course/presentation/progress/CourseProgressScreen.kt index 5d0a998d4..060eaf1f0 100644 --- a/course/src/main/java/org/openedx/course/presentation/progress/CourseProgressScreen.kt +++ b/course/src/main/java/org/openedx/course/presentation/progress/CourseProgressScreen.kt @@ -18,10 +18,11 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.widthIn -import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items import androidx.compose.foundation.shape.CircleShape -import androidx.compose.foundation.verticalScroll import androidx.compose.material.CircularProgressIndicator +import androidx.compose.material.Divider import androidx.compose.material.Icon import androidx.compose.material.MaterialTheme import androidx.compose.material.Scaffold @@ -36,14 +37,15 @@ import androidx.compose.runtime.remember 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.graphics.StrokeCap import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.buildAnnotatedString import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.withStyle import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp -import androidx.fragment.app.FragmentManager import org.openedx.core.NoContentScreenType import org.openedx.core.domain.model.CourseProgress import org.openedx.core.ui.CircularProgress @@ -62,7 +64,6 @@ import org.openedx.foundation.presentation.windowSizeValue fun CourseProgressScreen( windowSize: WindowSize, viewModel: CourseProgressViewModel, - fragmentManager: FragmentManager, ) { val uiState by viewModel.uiState.collectAsState() val uiMessage by viewModel.uiMessage.collectAsState(null) @@ -84,13 +85,6 @@ private fun CourseProgressContent( uiMessage: UIMessage?, windowSize: WindowSize ) { - // Mocked values for demo, replace with real data from progress - val completionPercent = 0.5f // 50% completed - val overallGrade = 0.52f // 52% - val gradeCutoff = uiState.progress.courseDetails.gradeCutoffs["A"] ?: 0.75f - val requiredGradePercent = (gradeCutoff * 100).toInt() - val currentGradePercent = (overallGrade * 100).toInt() - val scaffoldState = rememberScaffoldState() Scaffold( @@ -114,7 +108,6 @@ private fun CourseProgressContent( modifier = Modifier .fillMaxSize() .padding(it) - .verticalScroll(rememberScrollState()) .displayCutoutForLandscape(), contentAlignment = Alignment.TopCenter ) { @@ -122,22 +115,40 @@ private fun CourseProgressContent( modifier = screenWidth, color = MaterialTheme.appColors.background ) { - Box { - Column( - modifier = Modifier - .fillMaxSize() - .padding(16.dp), - verticalArrangement = Arrangement.spacedBy(16.dp) - ) { - CourseCompletionView(completionPercent) + LazyColumn( + modifier = Modifier + .fillMaxSize() + .padding(16.dp), + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + item { + CourseCompletionView( + progress = uiState.progress + ) + } + item { OverallGradeView( - courseProgress = uiState.progress, - currentGradePercent = currentGradePercent, - overallGrade = overallGrade, - gradeCutoff = gradeCutoff, - requiredGradePercent = requiredGradePercent + progress = uiState.progress, + ) + } + item { + GradeDetailsHeaderView() + } + items(uiState.progress.gradingPolicy.assignmentPolicies) { policy -> + AssignmentTypeRow( + progress = uiState.progress, + policy = policy + ) + Divider( + modifier = Modifier + .fillMaxWidth() + .padding(top = 8.dp) + ) + } + item { + GradeDetailsFooterView( + progress = uiState.progress ) - GradeDetailsView(uiState.progress) } } } @@ -145,35 +156,39 @@ private fun CourseProgressContent( } } -@Composable -private fun GradeDetailsView( - courseProgress: CourseProgress, -) { - Text( - text = stringResource(R.string.course_progress_grade_details), - style = MaterialTheme.appTypography.titleMedium, - color = MaterialTheme.appColors.textDark, - ) - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween +@Composable +private fun GradeDetailsHeaderView() { + Column( + verticalArrangement = Arrangement.spacedBy(8.dp) ) { Text( - text = stringResource(R.string.course_progress_assignment_type), - style = MaterialTheme.appTypography.bodySmall, - color = MaterialTheme.appColors.textPrimaryVariant, - ) - Text( - text = stringResource(R.string.course_progress_current_max), - style = MaterialTheme.appTypography.bodySmall, - color = MaterialTheme.appColors.textPrimaryVariant, + text = stringResource(R.string.course_progress_grade_details), + style = MaterialTheme.appTypography.titleMedium, + color = MaterialTheme.appColors.textDark, ) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween + ) { + Text( + text = stringResource(R.string.course_progress_assignment_type), + style = MaterialTheme.appTypography.bodySmall, + color = MaterialTheme.appColors.textPrimaryVariant, + ) + Text( + text = stringResource(R.string.course_progress_current_max), + style = MaterialTheme.appTypography.bodySmall, + color = MaterialTheme.appColors.textPrimaryVariant, + ) + } } - courseProgress.courseDetails.graders.forEach { grader -> - AssignmentTypeRow(grader) - } - Spacer(modifier = Modifier.height(8.dp)) +} + +@Composable +private fun GradeDetailsFooterView( + progress: CourseProgress, +) { Row( modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically, @@ -185,7 +200,7 @@ private fun GradeDetailsView( color = MaterialTheme.appColors.textDark, ) Text( - text = "777%", + text = "${progress.getTotalWeightPercent()}%", style = MaterialTheme.appTypography.labelLarge, color = MaterialTheme.appColors.primary, fontWeight = FontWeight.SemiBold @@ -195,78 +210,77 @@ private fun GradeDetailsView( @Composable private fun OverallGradeView( - courseProgress: CourseProgress, - currentGradePercent: Int, - overallGrade: Float, - gradeCutoff: Float, - requiredGradePercent: Int, + progress: CourseProgress, ) { - Text( - text = stringResource(R.string.course_progress_overall_title), - style = MaterialTheme.appTypography.titleMedium, - color = MaterialTheme.appColors.textDark, - ) - Text( - text = stringResource(R.string.course_progress_overall_description), - style = MaterialTheme.appTypography.labelMedium, - color = MaterialTheme.appColors.textDark, - ) - Row( - verticalAlignment = Alignment.Bottom, - horizontalArrangement = Arrangement.spacedBy(8.dp) + Column( + verticalArrangement = Arrangement.spacedBy(8.dp) ) { Text( - text = stringResource(R.string.course_progress_current_overall), - style = MaterialTheme.appTypography.labelMedium, + text = stringResource(R.string.course_progress_overall_title), + style = MaterialTheme.appTypography.titleMedium, color = MaterialTheme.appColors.textDark, ) Text( - text = "${currentGradePercent}%", + text = stringResource(R.string.course_progress_overall_description), style = MaterialTheme.appTypography.labelMedium, - color = MaterialTheme.appColors.primary, - fontWeight = FontWeight.SemiBold + color = MaterialTheme.appColors.textDark, ) - } - - Column { - val weightSum = courseProgress.courseDetails.gradeCutoffs.values.sum() - val weightMax = courseProgress.courseDetails.gradeCutoffs.size - val left = weightMax - weightSum Row( - modifier = Modifier - .fillMaxWidth() - .height(8.dp) - .clip(CircleShape) - .border( - width = 1.dp, - color = MaterialTheme.appColors.cardViewBackground, - shape = CircleShape - ) + verticalAlignment = Alignment.Bottom, + horizontalArrangement = Arrangement.spacedBy(8.dp) ) { - courseProgress.courseDetails.gradeCutoffs.toList().forEach { grade -> - val color = - courseProgress.courseDetails.graders.find { it.id == grade.first }?.color - ?: Color.Transparent + Text( + text = stringResource(R.string.course_progress_current_overall), + style = MaterialTheme.appTypography.labelMedium, + color = MaterialTheme.appColors.textDark, + ) + Text( + text = "${progress.getTotalWeightPercent()}%", + style = MaterialTheme.appTypography.labelMedium, + color = MaterialTheme.appColors.primary, + fontWeight = FontWeight.SemiBold + ) + } + + Column { + Row( + modifier = Modifier + .fillMaxWidth() + .height(8.dp) + .clip(CircleShape) + .border( + width = 1.dp, + color = MaterialTheme.appColors.cardViewBackground, + shape = CircleShape + ) + ) { + progress.gradingPolicy.assignmentPolicies.forEach { assignmentPolicy -> + // No color mapping in new model, use default + val color = MaterialTheme.appColors.primary + val weightedPercent = + progress.getAssignmentWeightedGradedPercent(assignmentPolicy) + if (weightedPercent > 0f) { + Box( + modifier = Modifier + .weight(weightedPercent) + .background(color) + .fillMaxHeight() + ) + } + } Box( modifier = Modifier - .weight(grade.second) - .background(color) + .weight(progress.getNotCompletedWeightedGradePercent()) .fillMaxHeight() ) } Box( modifier = Modifier - .weight(left) - .fillMaxHeight() - ) - } - if (left > 0f) { - Box( - modifier = Modifier - .fillMaxWidth((weightSum + left / 2) / weightMax), + .fillMaxWidth(progress.requiredGrade), contentAlignment = Alignment.CenterEnd ) { Box( + modifier = Modifier.offset(x = 20.dp), contentAlignment = Alignment.Center ) { Icon( @@ -276,49 +290,49 @@ private fun OverallGradeView( ) Text( modifier = Modifier.offset(y = 2.dp), - text = "$requiredGradePercent%", + text = "${progress.requiredGradePercent}%", style = MaterialTheme.appTypography.labelMedium, color = MaterialTheme.appColors.textDark, ) } } } - } - Surface( - color = MaterialTheme.appColors.cardViewBackground, - shape = MaterialTheme.appShapes.cardShape, - border = BorderStroke( - width = 1.dp, - color = MaterialTheme.appColors.warning - ), - modifier = Modifier.fillMaxWidth() - ) { - Row( - modifier = Modifier.padding(vertical = 12.dp, horizontal = 16.dp), + Surface( + color = MaterialTheme.appColors.cardViewBackground, + shape = MaterialTheme.appShapes.cardShape, + border = BorderStroke( + width = 1.dp, + color = MaterialTheme.appColors.warning + ), + modifier = Modifier.fillMaxWidth() ) { - Icon( - modifier = Modifier.size(16.dp), - painter = painterResource(id = android.R.drawable.ic_dialog_alert), - contentDescription = null, - tint = MaterialTheme.appColors.warning, - ) - Spacer(modifier = Modifier.width(8.dp)) - Text( - text = stringResource( - R.string.course_progress_required_grade_percent, - requiredGradePercent - ), - style = MaterialTheme.appTypography.labelLarge, - color = MaterialTheme.appColors.textDark, - ) + Row( + modifier = Modifier.padding(vertical = 12.dp, horizontal = 16.dp), + ) { + Icon( + modifier = Modifier.size(16.dp), + painter = painterResource(id = android.R.drawable.ic_dialog_alert), + contentDescription = null, + tint = MaterialTheme.appColors.warning, + ) + Spacer(modifier = Modifier.width(8.dp)) + Text( + text = stringResource( + R.string.course_progress_required_grade_percent, + progress.requiredGradePercent + ), + style = MaterialTheme.appTypography.labelLarge, + color = MaterialTheme.appColors.textDark, + ) + } } } } @Composable private fun CourseCompletionView( - completionPercent: Float + progress: CourseProgress ) { Row( horizontalArrangement = Arrangement.spacedBy(8.dp) @@ -351,7 +365,7 @@ private fun CourseCompletionView( shape = CircleShape ) .padding(3.dp), - progress = completionPercent, + progress = progress.completion, color = MaterialTheme.appColors.progressBarColor, backgroundColor = MaterialTheme.appColors.progressBarBackgroundColor, strokeWidth = 10.dp, @@ -362,12 +376,12 @@ private fun CourseCompletionView( horizontalAlignment = Alignment.CenterHorizontally ) { Text( - text = "${(completionPercent * 100).toInt()}%", + text = "${progress.completionPercent}%", style = MaterialTheme.appTypography.headlineSmall, color = MaterialTheme.appColors.primary, ) Text( - text = "Status", + text = stringResource(R.string.course_completed), style = MaterialTheme.appTypography.labelSmall, color = MaterialTheme.appColors.textPrimaryVariant, ) @@ -377,12 +391,17 @@ private fun CourseCompletionView( } @Composable -private fun AssignmentTypeRow(grader: CourseProgress.Grader) { +private fun AssignmentTypeRow( + progress: CourseProgress, + policy: CourseProgress.GradingPolicy.AssignmentPolicy +) { + val earned = progress.getEarnedAssignmentProblems(policy) + val possible = progress.getPossibleAssignmentProblems(policy) Column { Text( - text = grader.type, + text = policy.type, style = MaterialTheme.appTypography.labelLarge, - color = grader.color, + color = MaterialTheme.appColors.textPrimary, ) Row( Modifier @@ -396,27 +415,42 @@ private fun AssignmentTypeRow(grader: CourseProgress.Grader) { .fillMaxHeight() .width(7.dp) .background( - color = grader.color, + color = MaterialTheme.appColors.primary, shape = CircleShape ) ) Spacer(modifier = Modifier.width(8.dp)) Column( - modifier = Modifier.weight(1f) + modifier = Modifier.weight(1f), + verticalArrangement = Arrangement.spacedBy(4.dp) ) { Text( - text = "13 / 16 Complete", + text = stringResource( + R.string.progress_earned_possible_assignment_problems, + earned, + possible + ), style = MaterialTheme.appTypography.bodySmall, color = MaterialTheme.appColors.textDark, ) Text( - text = "${grader.weight}% of Grade", + text = buildAnnotatedString { + withStyle(style = SpanStyle(fontWeight = FontWeight.Bold)) { + append("${(policy.weight * 100).toInt()}%") + } + append(" ") + append(stringResource(R.string.progress_of_grade)) + }, style = MaterialTheme.appTypography.bodySmall, color = MaterialTheme.appColors.textDark, ) } Text( - "10 / 15%", + stringResource( + R.string.progress_current_and_max_weighted_graded_percent, + progress.getAssignmentWeightedGradedPercent(policy), + (policy.weight * 100).toInt() + ), style = MaterialTheme.appTypography.bodyLarge, fontWeight = FontWeight.W700, color = MaterialTheme.appColors.textDark, diff --git a/course/src/main/java/org/openedx/course/presentation/progress/CourseProgressViewModel.kt b/course/src/main/java/org/openedx/course/presentation/progress/CourseProgressViewModel.kt index f8dce044d..5c7d660b4 100644 --- a/course/src/main/java/org/openedx/course/presentation/progress/CourseProgressViewModel.kt +++ b/course/src/main/java/org/openedx/course/presentation/progress/CourseProgressViewModel.kt @@ -8,6 +8,9 @@ import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.launch +import org.openedx.core.system.notifier.CourseCompletionSet +import org.openedx.core.system.notifier.CourseNotifier +import org.openedx.core.system.notifier.RefreshProgress import org.openedx.course.domain.interactor.CourseInteractor import org.openedx.foundation.presentation.BaseViewModel import org.openedx.foundation.presentation.UIMessage @@ -15,6 +18,7 @@ import org.openedx.foundation.presentation.UIMessage class CourseProgressViewModel( val courseId: String, private val interactor: CourseInteractor, + private val courseNotifier: CourseNotifier, ) : BaseViewModel() { private val _uiState = MutableStateFlow(CourseProgressUIState.Loading) @@ -27,6 +31,7 @@ class CourseProgressViewModel( init { loadCourseProgress() + collectCourseNotifier() } fun loadCourseProgress() { @@ -35,9 +40,20 @@ class CourseProgressViewModel( try { val progress = interactor.getCourseProgress(courseId) _uiState.value = CourseProgressUIState.Data(progress) - } catch (e: Exception) { + } catch (_: Exception) { _uiState.value = CourseProgressUIState.Error } } } + + + private fun collectCourseNotifier() { + viewModelScope.launch { + courseNotifier.notifier.collect { event -> + when (event) { + is RefreshProgress, is CourseCompletionSet -> loadCourseProgress() + } + } + } + } } diff --git a/course/src/main/res/values/strings.xml b/course/src/main/res/values/strings.xml index c4ef714ee..8eda59bf4 100644 --- a/course/src/main/res/values/strings.xml +++ b/course/src/main/res/values/strings.xml @@ -43,8 +43,8 @@ Current Overall Weighted Grade: A weighted grade of %1$d%% is required to pass this course Grade Details - ASSIGNMENT TYPE - CURRENT - MAX + Assignment Type + Current / Max % Course dates are not currently available. @@ -76,4 +76,8 @@ Find a new course This course will begin on %s. Come back then to start learning! An error occurred while loading your course + Completed + %1$s / %2$s Complete + of Grade + %1$s / %2$s%% From 6e23b390eab38405518bc98cc0b510e87aa3a92b Mon Sep 17 00:00:00 2001 From: PavloNetrebchuk Date: Mon, 23 Jun 2025 16:05:49 +0300 Subject: [PATCH 03/10] feat: cache first logic --- .../org.openedx.app.room.AppDatabase/3.json | 1192 +++++++++++++++++ .../java/org/openedx/app/room/AppDatabase.kt | 9 +- .../org/openedx/app/room/DatabaseManager.kt | 3 +- .../core/data/model/CourseProgressResponse.kt | 82 ++ .../data/model/room/CourseProgressEntity.kt | 222 +++ .../openedx/core/data/storage/CourseDao.kt | 12 +- .../data/repository/CourseRepository.kt | 21 +- .../course/data/storage/CourseConverter.kt | 56 +- .../domain/interactor/CourseInteractor.kt | 15 +- .../progress/CourseProgressViewModel.kt | 35 +- 10 files changed, 1594 insertions(+), 53 deletions(-) create mode 100644 app/schemas/org.openedx.app.room.AppDatabase/3.json create mode 100644 core/src/main/java/org/openedx/core/data/model/room/CourseProgressEntity.kt diff --git a/app/schemas/org.openedx.app.room.AppDatabase/3.json b/app/schemas/org.openedx.app.room.AppDatabase/3.json new file mode 100644 index 000000000..ca04113c5 --- /dev/null +++ b/app/schemas/org.openedx.app.room.AppDatabase/3.json @@ -0,0 +1,1192 @@ +{ + "formatVersion": 1, + "database": { + "version": 3, + "identityHash": "635efa727d2d55fb80079fa22d5bea62", + "entities": [ + { + "tableName": "course_discovery_table", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `blocksUrl` TEXT NOT NULL, `courseId` TEXT NOT NULL, `effort` TEXT NOT NULL, `enrollmentStart` TEXT NOT NULL, `enrollmentEnd` TEXT NOT NULL, `hidden` INTEGER NOT NULL, `invitationOnly` INTEGER NOT NULL, `mobileAvailable` INTEGER NOT NULL, `name` TEXT NOT NULL, `number` TEXT NOT NULL, `org` TEXT NOT NULL, `pacing` TEXT NOT NULL, `shortDescription` TEXT NOT NULL, `start` TEXT NOT NULL, `end` TEXT NOT NULL, `startDisplay` TEXT NOT NULL, `startType` TEXT NOT NULL, `overview` TEXT NOT NULL, `isEnrolled` INTEGER NOT NULL, `bannerImage` TEXT, `courseImage` TEXT, `courseVideo` TEXT, `image` TEXT, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "blocksUrl", + "columnName": "blocksUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "courseId", + "columnName": "courseId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "effort", + "columnName": "effort", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "enrollmentStart", + "columnName": "enrollmentStart", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "enrollmentEnd", + "columnName": "enrollmentEnd", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "hidden", + "columnName": "hidden", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "invitationOnly", + "columnName": "invitationOnly", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "mobileAvailable", + "columnName": "mobileAvailable", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "number", + "columnName": "number", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "org", + "columnName": "org", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "pacing", + "columnName": "pacing", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "shortDescription", + "columnName": "shortDescription", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "start", + "columnName": "start", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "end", + "columnName": "end", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "startDisplay", + "columnName": "startDisplay", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "startType", + "columnName": "startType", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "overview", + "columnName": "overview", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "isEnrolled", + "columnName": "isEnrolled", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "media.bannerImage", + "columnName": "bannerImage", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "media.courseImage", + "columnName": "courseImage", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "media.courseVideo", + "columnName": "courseVideo", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "media.image", + "columnName": "image", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "course_enrolled_table", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`courseId` TEXT NOT NULL, `auditAccessExpires` TEXT NOT NULL, `created` TEXT NOT NULL, `mode` TEXT NOT NULL, `isActive` INTEGER NOT NULL, `id` TEXT NOT NULL, `name` TEXT NOT NULL, `number` TEXT NOT NULL, `org` TEXT NOT NULL, `start` TEXT NOT NULL, `startDisplay` TEXT NOT NULL, `startType` TEXT NOT NULL, `end` TEXT NOT NULL, `dynamicUpgradeDeadline` TEXT NOT NULL, `subscriptionId` TEXT NOT NULL, `course_image_link` TEXT NOT NULL, `courseAbout` TEXT NOT NULL, `courseUpdates` TEXT NOT NULL, `courseHandouts` TEXT NOT NULL, `discussionUrl` TEXT NOT NULL, `videoOutline` TEXT NOT NULL, `isSelfPaced` INTEGER NOT NULL, `hasAccess` INTEGER, `errorCode` TEXT, `developerMessage` TEXT, `userMessage` TEXT, `additionalContextUserMessage` TEXT, `userFragment` TEXT, `bannerImage` TEXT, `courseImage` TEXT, `courseVideo` TEXT, `image` TEXT, `facebook` TEXT NOT NULL, `twitter` TEXT NOT NULL, `certificateURL` TEXT, `assignments_completed` INTEGER NOT NULL, `total_assignments_count` INTEGER NOT NULL, `lastVisitedModuleId` TEXT, `lastVisitedModulePath` TEXT, `lastVisitedBlockId` TEXT, `lastVisitedUnitDisplayName` TEXT, `futureAssignments` TEXT, `pastAssignments` TEXT, PRIMARY KEY(`courseId`))", + "fields": [ + { + "fieldPath": "courseId", + "columnName": "courseId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "auditAccessExpires", + "columnName": "auditAccessExpires", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "created", + "columnName": "created", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "mode", + "columnName": "mode", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "isActive", + "columnName": "isActive", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "course.id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "course.name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "course.number", + "columnName": "number", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "course.org", + "columnName": "org", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "course.start", + "columnName": "start", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "course.startDisplay", + "columnName": "startDisplay", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "course.startType", + "columnName": "startType", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "course.end", + "columnName": "end", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "course.dynamicUpgradeDeadline", + "columnName": "dynamicUpgradeDeadline", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "course.subscriptionId", + "columnName": "subscriptionId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "course.courseImage", + "columnName": "course_image_link", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "course.courseAbout", + "columnName": "courseAbout", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "course.courseUpdates", + "columnName": "courseUpdates", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "course.courseHandouts", + "columnName": "courseHandouts", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "course.discussionUrl", + "columnName": "discussionUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "course.videoOutline", + "columnName": "videoOutline", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "course.isSelfPaced", + "columnName": "isSelfPaced", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "course.coursewareAccess.hasAccess", + "columnName": "hasAccess", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "course.coursewareAccess.errorCode", + "columnName": "errorCode", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "course.coursewareAccess.developerMessage", + "columnName": "developerMessage", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "course.coursewareAccess.userMessage", + "columnName": "userMessage", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "course.coursewareAccess.additionalContextUserMessage", + "columnName": "additionalContextUserMessage", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "course.coursewareAccess.userFragment", + "columnName": "userFragment", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "course.media.bannerImage", + "columnName": "bannerImage", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "course.media.courseImage", + "columnName": "courseImage", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "course.media.courseVideo", + "columnName": "courseVideo", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "course.media.image", + "columnName": "image", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "course.courseSharingUtmParameters.facebook", + "columnName": "facebook", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "course.courseSharingUtmParameters.twitter", + "columnName": "twitter", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "certificate.certificateURL", + "columnName": "certificateURL", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "progress.assignmentsCompleted", + "columnName": "assignments_completed", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "progress.totalAssignmentsCount", + "columnName": "total_assignments_count", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "courseStatus.lastVisitedModuleId", + "columnName": "lastVisitedModuleId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "courseStatus.lastVisitedModulePath", + "columnName": "lastVisitedModulePath", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "courseStatus.lastVisitedBlockId", + "columnName": "lastVisitedBlockId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "courseStatus.lastVisitedUnitDisplayName", + "columnName": "lastVisitedUnitDisplayName", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "courseAssignments.futureAssignments", + "columnName": "futureAssignments", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "courseAssignments.pastAssignments", + "columnName": "pastAssignments", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "courseId" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "course_structure_table", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`root` TEXT NOT NULL, `id` TEXT NOT NULL, `blocks` TEXT NOT NULL, `name` TEXT NOT NULL, `number` TEXT NOT NULL, `org` TEXT NOT NULL, `start` TEXT, `startDisplay` TEXT NOT NULL, `startType` TEXT NOT NULL, `end` TEXT, `isSelfPaced` INTEGER NOT NULL, `hasAccess` INTEGER, `errorCode` TEXT, `developerMessage` TEXT, `userMessage` TEXT, `additionalContextUserMessage` TEXT, `userFragment` TEXT, `bannerImage` TEXT, `courseImage` TEXT, `courseVideo` TEXT, `image` TEXT, `certificateURL` TEXT, `assignments_completed` INTEGER NOT NULL, `total_assignments_count` INTEGER NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "root", + "columnName": "root", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "blocks", + "columnName": "blocks", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "number", + "columnName": "number", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "org", + "columnName": "org", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "start", + "columnName": "start", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "startDisplay", + "columnName": "startDisplay", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "startType", + "columnName": "startType", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "end", + "columnName": "end", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "isSelfPaced", + "columnName": "isSelfPaced", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "coursewareAccess.hasAccess", + "columnName": "hasAccess", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "coursewareAccess.errorCode", + "columnName": "errorCode", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "coursewareAccess.developerMessage", + "columnName": "developerMessage", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "coursewareAccess.userMessage", + "columnName": "userMessage", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "coursewareAccess.additionalContextUserMessage", + "columnName": "additionalContextUserMessage", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "coursewareAccess.userFragment", + "columnName": "userFragment", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "media.bannerImage", + "columnName": "bannerImage", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "media.courseImage", + "columnName": "courseImage", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "media.courseVideo", + "columnName": "courseVideo", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "media.image", + "columnName": "image", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "certificate.certificateURL", + "columnName": "certificateURL", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "progress.assignmentsCompleted", + "columnName": "assignments_completed", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "progress.totalAssignmentsCount", + "columnName": "total_assignments_count", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "download_model", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `title` TEXT NOT NULL, `courseId` TEXT NOT NULL, `size` INTEGER NOT NULL, `path` TEXT NOT NULL, `url` TEXT NOT NULL, `type` TEXT NOT NULL, `downloadedState` TEXT NOT NULL, `lastModified` TEXT, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "courseId", + "columnName": "courseId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "size", + "columnName": "size", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "path", + "columnName": "path", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "downloadedState", + "columnName": "downloadedState", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lastModified", + "columnName": "lastModified", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "offline_x_block_progress_table", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `courseId` TEXT NOT NULL, `url` TEXT NOT NULL, `type` TEXT NOT NULL, `data` TEXT NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "blockId", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "courseId", + "columnName": "courseId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "jsonProgress.url", + "columnName": "url", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "jsonProgress.type", + "columnName": "type", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "jsonProgress.data", + "columnName": "data", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "course_calendar_event_table", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`event_id` INTEGER NOT NULL, `course_id` TEXT NOT NULL, PRIMARY KEY(`event_id`))", + "fields": [ + { + "fieldPath": "eventId", + "columnName": "event_id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "courseId", + "columnName": "course_id", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "event_id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "course_calendar_state_table", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`course_id` TEXT NOT NULL, `checksum` INTEGER NOT NULL, `is_course_sync_enabled` INTEGER NOT NULL, PRIMARY KEY(`course_id`))", + "fields": [ + { + "fieldPath": "courseId", + "columnName": "course_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "checksum", + "columnName": "checksum", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isCourseSyncEnabled", + "columnName": "is_course_sync_enabled", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "course_id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "download_course_preview_table", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`course_id` TEXT NOT NULL, `course_name` TEXT, `course_image` TEXT, `total_size` INTEGER, PRIMARY KEY(`course_id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "course_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "course_name", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "image", + "columnName": "course_image", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "totalSize", + "columnName": "total_size", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "course_id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "course_enrollment_details_table", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `courseUpdates` TEXT NOT NULL, `courseHandouts` TEXT NOT NULL, `discussionUrl` TEXT NOT NULL, `hasUnmetPrerequisites` INTEGER NOT NULL, `isTooEarly` INTEGER NOT NULL, `isStaff` INTEGER NOT NULL, `auditAccessExpires` TEXT, `hasAccess` INTEGER, `errorCode` TEXT, `developerMessage` TEXT, `userMessage` TEXT, `additionalContextUserMessage` TEXT, `userFragment` TEXT, `certificateURL` TEXT, `created` TEXT, `mode` TEXT, `isActive` INTEGER NOT NULL, `upgradeDeadline` TEXT, `name` TEXT NOT NULL, `number` TEXT NOT NULL, `org` TEXT NOT NULL, `startDisplay` TEXT NOT NULL, `startType` TEXT NOT NULL, `isSelfPaced` INTEGER NOT NULL, `courseAbout` TEXT NOT NULL, `bannerImage` TEXT, `courseImage` TEXT, `courseVideo` TEXT, `image` TEXT, `facebook` TEXT NOT NULL, `twitter` TEXT NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "courseUpdates", + "columnName": "courseUpdates", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "courseHandouts", + "columnName": "courseHandouts", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "discussionUrl", + "columnName": "discussionUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "courseAccessDetails.hasUnmetPrerequisites", + "columnName": "hasUnmetPrerequisites", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "courseAccessDetails.isTooEarly", + "columnName": "isTooEarly", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "courseAccessDetails.isStaff", + "columnName": "isStaff", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "courseAccessDetails.auditAccessExpires", + "columnName": "auditAccessExpires", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "courseAccessDetails.coursewareAccess.hasAccess", + "columnName": "hasAccess", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "courseAccessDetails.coursewareAccess.errorCode", + "columnName": "errorCode", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "courseAccessDetails.coursewareAccess.developerMessage", + "columnName": "developerMessage", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "courseAccessDetails.coursewareAccess.userMessage", + "columnName": "userMessage", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "courseAccessDetails.coursewareAccess.additionalContextUserMessage", + "columnName": "additionalContextUserMessage", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "courseAccessDetails.coursewareAccess.userFragment", + "columnName": "userFragment", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "certificate.certificateURL", + "columnName": "certificateURL", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "enrollmentDetails.created", + "columnName": "created", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "enrollmentDetails.mode", + "columnName": "mode", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "enrollmentDetails.isActive", + "columnName": "isActive", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "enrollmentDetails.upgradeDeadline", + "columnName": "upgradeDeadline", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "courseInfoOverview.name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "courseInfoOverview.number", + "columnName": "number", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "courseInfoOverview.org", + "columnName": "org", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "courseInfoOverview.startDisplay", + "columnName": "startDisplay", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "courseInfoOverview.startType", + "columnName": "startType", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "courseInfoOverview.isSelfPaced", + "columnName": "isSelfPaced", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "courseInfoOverview.courseAbout", + "columnName": "courseAbout", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "courseInfoOverview.media.bannerImage", + "columnName": "bannerImage", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "courseInfoOverview.media.courseImage", + "columnName": "courseImage", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "courseInfoOverview.media.courseVideo", + "columnName": "courseVideo", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "courseInfoOverview.media.image", + "columnName": "image", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "courseInfoOverview.courseSharingUtmParameters.facebook", + "columnName": "facebook", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "courseInfoOverview.courseSharingUtmParameters.twitter", + "columnName": "twitter", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "course_progress_table", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`courseId` TEXT NOT NULL, `verifiedMode` TEXT NOT NULL, `accessExpiration` TEXT NOT NULL, `creditCourseRequirements` TEXT NOT NULL, `end` TEXT NOT NULL, `enrollmentMode` TEXT NOT NULL, `hasScheduledContent` INTEGER NOT NULL, `sectionScores` TEXT NOT NULL, `studioUrl` TEXT NOT NULL, `username` TEXT NOT NULL, `userHasPassingGrade` INTEGER NOT NULL, `disableProgressGraph` INTEGER NOT NULL, `certificate_certStatus` TEXT NOT NULL, `certificate_certWebViewUrl` TEXT NOT NULL, `certificate_downloadUrl` TEXT NOT NULL, `certificate_certificateAvailableDate` TEXT NOT NULL, `completion_completeCount` INTEGER NOT NULL, `completion_incompleteCount` INTEGER NOT NULL, `completion_lockedCount` INTEGER NOT NULL, `grade_letterGrade` TEXT NOT NULL, `grade_percent` REAL NOT NULL, `grade_isPassing` INTEGER NOT NULL, `grading_assignmentPolicies` TEXT NOT NULL, `grading_gradeRange` TEXT NOT NULL, `verification_link` TEXT NOT NULL, `verification_status` TEXT NOT NULL, `verification_statusDate` TEXT NOT NULL, PRIMARY KEY(`courseId`))", + "fields": [ + { + "fieldPath": "courseId", + "columnName": "courseId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "verifiedMode", + "columnName": "verifiedMode", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "accessExpiration", + "columnName": "accessExpiration", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "creditCourseRequirements", + "columnName": "creditCourseRequirements", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "end", + "columnName": "end", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "enrollmentMode", + "columnName": "enrollmentMode", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "hasScheduledContent", + "columnName": "hasScheduledContent", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "sectionScores", + "columnName": "sectionScores", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "studioUrl", + "columnName": "studioUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "username", + "columnName": "username", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "userHasPassingGrade", + "columnName": "userHasPassingGrade", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "disableProgressGraph", + "columnName": "disableProgressGraph", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "certificateData.certStatus", + "columnName": "certificate_certStatus", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "certificateData.certWebViewUrl", + "columnName": "certificate_certWebViewUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "certificateData.downloadUrl", + "columnName": "certificate_downloadUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "certificateData.certificateAvailableDate", + "columnName": "certificate_certificateAvailableDate", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "completionSummary.completeCount", + "columnName": "completion_completeCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "completionSummary.incompleteCount", + "columnName": "completion_incompleteCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "completionSummary.lockedCount", + "columnName": "completion_lockedCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "courseGrade.letterGrade", + "columnName": "grade_letterGrade", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "courseGrade.percent", + "columnName": "grade_percent", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "courseGrade.isPassing", + "columnName": "grade_isPassing", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "gradingPolicy.assignmentPolicies", + "columnName": "grading_assignmentPolicies", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "gradingPolicy.gradeRange", + "columnName": "grading_gradeRange", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "verificationData.link", + "columnName": "verification_link", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "verificationData.status", + "columnName": "verification_status", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "verificationData.statusDate", + "columnName": "verification_statusDate", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "courseId" + ] + }, + "indices": [], + "foreignKeys": [] + } + ], + "views": [], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '635efa727d2d55fb80079fa22d5bea62')" + ] + } +} \ No newline at end of file diff --git a/app/src/main/java/org/openedx/app/room/AppDatabase.kt b/app/src/main/java/org/openedx/app/room/AppDatabase.kt index bfdcee43f..b5dfde4da 100644 --- a/app/src/main/java/org/openedx/app/room/AppDatabase.kt +++ b/app/src/main/java/org/openedx/app/room/AppDatabase.kt @@ -7,6 +7,7 @@ import androidx.room.TypeConverters import org.openedx.core.data.model.room.CourseCalendarEventEntity import org.openedx.core.data.model.room.CourseCalendarStateEntity import org.openedx.core.data.model.room.CourseEnrollmentDetailsEntity +import org.openedx.core.data.model.room.CourseProgressEntity import org.openedx.core.data.model.room.CourseStructureEntity import org.openedx.core.data.model.room.DownloadCoursePreview import org.openedx.core.data.model.room.OfflineXBlockProgress @@ -21,7 +22,7 @@ import org.openedx.discovery.data.converter.DiscoveryConverter import org.openedx.discovery.data.model.room.CourseEntity import org.openedx.discovery.data.storage.DiscoveryDao -const val DATABASE_VERSION = 2 +const val DATABASE_VERSION = 3 const val DATABASE_NAME = "OpenEdX_db" @Database( @@ -34,10 +35,12 @@ const val DATABASE_NAME = "OpenEdX_db" CourseCalendarEventEntity::class, CourseCalendarStateEntity::class, DownloadCoursePreview::class, - CourseEnrollmentDetailsEntity::class + CourseEnrollmentDetailsEntity::class, + CourseProgressEntity::class, ], autoMigrations = [ - AutoMigration(1, DATABASE_VERSION) + AutoMigration(1, 2), + AutoMigration(2, DATABASE_VERSION), ], version = DATABASE_VERSION ) diff --git a/app/src/main/java/org/openedx/app/room/DatabaseManager.kt b/app/src/main/java/org/openedx/app/room/DatabaseManager.kt index d24eb54f9..d3470f3b8 100644 --- a/app/src/main/java/org/openedx/app/room/DatabaseManager.kt +++ b/app/src/main/java/org/openedx/app/room/DatabaseManager.kt @@ -17,8 +17,9 @@ class DatabaseManager( ) : DatabaseManager { override fun clearTables() { CoroutineScope(Dispatchers.IO).launch { - courseDao.clearCachedData() + courseDao.clearCourseStructureData() courseDao.clearEnrollmentCachedData() + courseDao.clearCourseProgressData() dashboardDao.clearCachedData() downloadDao.clearOfflineProgress() discoveryDao.clearCachedData() diff --git a/core/src/main/java/org/openedx/core/data/model/CourseProgressResponse.kt b/core/src/main/java/org/openedx/core/data/model/CourseProgressResponse.kt index f4c784443..75a139dbf 100644 --- a/core/src/main/java/org/openedx/core/data/model/CourseProgressResponse.kt +++ b/core/src/main/java/org/openedx/core/data/model/CourseProgressResponse.kt @@ -1,6 +1,12 @@ package org.openedx.core.data.model import com.google.gson.annotations.SerializedName +import org.openedx.core.data.model.room.CertificateDataDb +import org.openedx.core.data.model.room.CompletionSummaryDb +import org.openedx.core.data.model.room.CourseGradeDb +import org.openedx.core.data.model.room.CourseProgressEntity +import org.openedx.core.data.model.room.GradingPolicyDb +import org.openedx.core.data.model.room.SectionScoreDb import org.openedx.core.domain.model.CourseProgress data class CourseProgressResponse( @@ -159,4 +165,80 @@ data class CourseProgressResponse( disableProgressGraph = disableProgressGraph ?: false ) } + + fun mapToRoomEntity(courseId: String): CourseProgressEntity { + return CourseProgressEntity( + courseId = courseId, + verifiedMode = verifiedMode ?: "", + accessExpiration = accessExpiration ?: "", + certificateData = CertificateDataDb( + certStatus = certificateData?.certStatus ?: "", + certWebViewUrl = certificateData?.certWebViewUrl ?: "", + downloadUrl = certificateData?.downloadUrl ?: "", + certificateAvailableDate = certificateData?.certificateAvailableDate ?: "" + ), + completionSummary = CompletionSummaryDb( + completeCount = completionSummary?.completeCount ?: 0, + incompleteCount = completionSummary?.incompleteCount ?: 0, + lockedCount = completionSummary?.lockedCount ?: 0 + ), + courseGrade = CourseGradeDb( + letterGrade = courseGrade?.letterGrade ?: "", + percent = courseGrade?.percent ?: 0.0, + isPassing = courseGrade?.isPassing ?: false + ), + creditCourseRequirements = creditCourseRequirements ?: "", + end = end ?: "", + enrollmentMode = enrollmentMode ?: "", + gradingPolicy = GradingPolicyDb( + assignmentPolicies = gradingPolicy?.assignmentPolicies?.map { + GradingPolicyDb.AssignmentPolicyDb( + numDroppable = it.numDroppable ?: 0, + numTotal = it.numTotal ?: 0, + shortLabel = it.shortLabel ?: "", + type = it.type ?: "", + weight = it.weight ?: 0.0 + ) + } ?: emptyList(), + gradeRange = gradingPolicy?.gradeRange ?: emptyMap() + ), + hasScheduledContent = hasScheduledContent ?: false, + sectionScores = sectionScores?.map { section -> + SectionScoreDb( + displayName = section.displayName ?: "", + subsections = section.subsections?.map { subsection -> + SectionScoreDb.SubsectionDb( + assignmentType = subsection.assignmentType ?: "", + blockKey = subsection.blockKey ?: "", + displayName = subsection.displayName ?: "", + hasGradedAssignment = subsection.hasGradedAssignment ?: false, + override = subsection.override ?: "", + learnerHasAccess = subsection.learnerHasAccess ?: false, + numPointsEarned = subsection.numPointsEarned ?: 0, + numPointsPossible = subsection.numPointsPossible ?: 0, + percentGraded = subsection.percentGraded ?: 0.0, + problemScores = subsection.problemScores?.map { problemScore -> + SectionScoreDb.SubsectionDb.ProblemScoreDb( + earned = problemScore.earned ?: 0, + possible = problemScore.possible ?: 0 + ) + } ?: emptyList(), + showCorrectness = subsection.showCorrectness ?: "", + showGrades = subsection.showGrades ?: false, + url = subsection.url ?: "" + ) + } ?: emptyList() + ) + } ?: emptyList(), + studioUrl = studioUrl ?: "", + username = username ?: "", + userHasPassingGrade = userHasPassingGrade ?: false, + verificationData = org.openedx.core.data.model.room.VerificationDataDb( + link = verificationData?.link ?: "", + status = verificationData?.status ?: "", + statusDate = verificationData?.statusDate ?: "" + ), + disableProgressGraph = disableProgressGraph ?: false + ) + } } diff --git a/core/src/main/java/org/openedx/core/data/model/room/CourseProgressEntity.kt b/core/src/main/java/org/openedx/core/data/model/room/CourseProgressEntity.kt new file mode 100644 index 000000000..dca580196 --- /dev/null +++ b/core/src/main/java/org/openedx/core/data/model/room/CourseProgressEntity.kt @@ -0,0 +1,222 @@ +package org.openedx.core.data.model.room + +import androidx.room.ColumnInfo +import androidx.room.Embedded +import androidx.room.Entity +import androidx.room.PrimaryKey +import org.openedx.core.domain.model.CourseProgress + +@Entity(tableName = "course_progress_table") +data class CourseProgressEntity( + @PrimaryKey + @ColumnInfo("courseId") + val courseId: String, + @ColumnInfo("verifiedMode") + val verifiedMode: String, + @ColumnInfo("accessExpiration") + val accessExpiration: String, + @Embedded(prefix = "certificate_") + val certificateData: CertificateDataDb, + @Embedded(prefix = "completion_") + val completionSummary: CompletionSummaryDb, + @Embedded(prefix = "grade_") + val courseGrade: CourseGradeDb, + @ColumnInfo("creditCourseRequirements") + val creditCourseRequirements: String, + @ColumnInfo("end") + val end: String, + @ColumnInfo("enrollmentMode") + val enrollmentMode: String, + @Embedded(prefix = "grading_") + val gradingPolicy: GradingPolicyDb, + @ColumnInfo("hasScheduledContent") + val hasScheduledContent: Boolean, + @ColumnInfo("sectionScores") + val sectionScores: List, + @ColumnInfo("studioUrl") + val studioUrl: String, + @ColumnInfo("username") + val username: String, + @ColumnInfo("userHasPassingGrade") + val userHasPassingGrade: Boolean, + @Embedded(prefix = "verification_") + val verificationData: VerificationDataDb, + @ColumnInfo("disableProgressGraph") + val disableProgressGraph: Boolean +) { + fun mapToDomain(): CourseProgress { + return CourseProgress( + verifiedMode = verifiedMode, + accessExpiration = accessExpiration, + certificateData = CourseProgress.CertificateData( + certStatus = certificateData.certStatus, + certWebViewUrl = certificateData.certWebViewUrl, + downloadUrl = certificateData.downloadUrl, + certificateAvailableDate = certificateData.certificateAvailableDate + ), + completionSummary = CourseProgress.CompletionSummary( + completeCount = completionSummary.completeCount, + incompleteCount = completionSummary.incompleteCount, + lockedCount = completionSummary.lockedCount + ), + courseGrade = CourseProgress.CourseGrade( + letterGrade = courseGrade.letterGrade, + percent = courseGrade.percent, + isPassing = courseGrade.isPassing + ), + creditCourseRequirements = creditCourseRequirements, + end = end, + enrollmentMode = enrollmentMode, + gradingPolicy = CourseProgress.GradingPolicy( + assignmentPolicies = gradingPolicy.assignmentPolicies.map { + CourseProgress.GradingPolicy.AssignmentPolicy( + numDroppable = it.numDroppable, + numTotal = it.numTotal, + shortLabel = it.shortLabel, + type = it.type, + weight = it.weight + ) + }, + gradeRange = gradingPolicy.gradeRange + ), + hasScheduledContent = hasScheduledContent, + sectionScores = sectionScores.map { section -> + CourseProgress.SectionScore( + displayName = section.displayName, + subsections = section.subsections.map { subsection -> + CourseProgress.SectionScore.Subsection( + assignmentType = subsection.assignmentType, + blockKey = subsection.blockKey, + displayName = subsection.displayName, + hasGradedAssignment = subsection.hasGradedAssignment, + override = subsection.override, + learnerHasAccess = subsection.learnerHasAccess, + numPointsEarned = subsection.numPointsEarned, + numPointsPossible = subsection.numPointsPossible, + percentGraded = subsection.percentGraded, + problemScores = subsection.problemScores.map { problemScore -> + CourseProgress.SectionScore.Subsection.ProblemScore( + earned = problemScore.earned, + possible = problemScore.possible + ) + }, + showCorrectness = subsection.showCorrectness, + showGrades = subsection.showGrades, + url = subsection.url + ) + } + ) + }, + studioUrl = studioUrl, + username = username, + userHasPassingGrade = userHasPassingGrade, + verificationData = CourseProgress.VerificationData( + link = verificationData.link, + status = verificationData.status, + statusDate = verificationData.statusDate + ), + disableProgressGraph = disableProgressGraph + ) + } +} + +data class CertificateDataDb( + @ColumnInfo("certStatus") + val certStatus: String, + @ColumnInfo("certWebViewUrl") + val certWebViewUrl: String, + @ColumnInfo("downloadUrl") + val downloadUrl: String, + @ColumnInfo("certificateAvailableDate") + val certificateAvailableDate: String +) + +data class CompletionSummaryDb( + @ColumnInfo("completeCount") + val completeCount: Int, + @ColumnInfo("incompleteCount") + val incompleteCount: Int, + @ColumnInfo("lockedCount") + val lockedCount: Int +) + +data class CourseGradeDb( + @ColumnInfo("letterGrade") + val letterGrade: String, + @ColumnInfo("percent") + val percent: Double, + @ColumnInfo("isPassing") + val isPassing: Boolean +) + +data class GradingPolicyDb( + @ColumnInfo("assignmentPolicies") + val assignmentPolicies: List, + @ColumnInfo("gradeRange") + val gradeRange: Map +) { + data class AssignmentPolicyDb( + @ColumnInfo("numDroppable") + val numDroppable: Int, + @ColumnInfo("numTotal") + val numTotal: Int, + @ColumnInfo("shortLabel") + val shortLabel: String, + @ColumnInfo("type") + val type: String, + @ColumnInfo("weight") + val weight: Double + ) +} + +data class SectionScoreDb( + @ColumnInfo("displayName") + val displayName: String, + @ColumnInfo("subsections") + val subsections: List +) { + data class SubsectionDb( + @ColumnInfo("assignmentType") + val assignmentType: String, + @ColumnInfo("blockKey") + val blockKey: String, + @ColumnInfo("displayName") + val displayName: String, + @ColumnInfo("hasGradedAssignment") + val hasGradedAssignment: Boolean, + @ColumnInfo("override") + val override: String, + @ColumnInfo("learnerHasAccess") + val learnerHasAccess: Boolean, + @ColumnInfo("numPointsEarned") + val numPointsEarned: Int, + @ColumnInfo("numPointsPossible") + val numPointsPossible: Int, + @ColumnInfo("percentGraded") + val percentGraded: Double, + @ColumnInfo("problemScores") + val problemScores: List, + @ColumnInfo("showCorrectness") + val showCorrectness: String, + @ColumnInfo("showGrades") + val showGrades: Boolean, + @ColumnInfo("url") + val url: String + ) { + data class ProblemScoreDb( + @ColumnInfo("earned") + val earned: Int, + @ColumnInfo("possible") + val possible: Int + ) + } +} + +data class VerificationDataDb( + @ColumnInfo("link") + val link: String, + @ColumnInfo("status") + val status: String, + @ColumnInfo("statusDate") + val statusDate: String +) diff --git a/core/src/main/java/org/openedx/core/data/storage/CourseDao.kt b/core/src/main/java/org/openedx/core/data/storage/CourseDao.kt index 1ce813242..fe216af83 100644 --- a/core/src/main/java/org/openedx/core/data/storage/CourseDao.kt +++ b/core/src/main/java/org/openedx/core/data/storage/CourseDao.kt @@ -5,6 +5,7 @@ import androidx.room.Insert import androidx.room.OnConflictStrategy import androidx.room.Query import org.openedx.core.data.model.room.CourseEnrollmentDetailsEntity +import org.openedx.core.data.model.room.CourseProgressEntity import org.openedx.core.data.model.room.CourseStructureEntity @Dao @@ -17,7 +18,10 @@ interface CourseDao { suspend fun insertCourseStructureEntity(vararg courseStructureEntity: CourseStructureEntity) @Query("DELETE FROM course_structure_table") - suspend fun clearCachedData() + suspend fun clearCourseStructureData() + + @Query("DELETE FROM course_progress_table") + suspend fun clearCourseProgressData() @Insert(onConflict = OnConflictStrategy.REPLACE) suspend fun insertCourseEnrollmentDetailsEntity(vararg courseEnrollmentDetailsEntity: CourseEnrollmentDetailsEntity) @@ -27,4 +31,10 @@ interface CourseDao { @Query("DELETE FROM course_enrollment_details_table") suspend fun clearEnrollmentCachedData() + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun insertCourseProgressEntity(vararg courseProgressEntity: CourseProgressEntity) + + @Query("SELECT * FROM course_progress_table WHERE courseId=:id") + suspend fun getCourseProgressById(id: String): CourseProgressEntity? } diff --git a/course/src/main/java/org/openedx/course/data/repository/CourseRepository.kt b/course/src/main/java/org/openedx/course/data/repository/CourseRepository.kt index ec824f561..914ce7191 100644 --- a/course/src/main/java/org/openedx/course/data/repository/CourseRepository.kt +++ b/course/src/main/java/org/openedx/course/data/repository/CourseRepository.kt @@ -46,7 +46,10 @@ class CourseRepository( suspend fun getAllDownloadModels() = downloadDao.readAllData().map { it.mapToDomain() } - suspend fun getCourseStructureFlow(courseId: String, forceRefresh: Boolean = true): Flow = + suspend fun getCourseStructureFlow( + courseId: String, + forceRefresh: Boolean = true + ): Flow = channelFlowWithAwait { var hasCourseStructure = false val cachedCourseStructure = courseStructure[courseId] ?: ( @@ -237,8 +240,16 @@ class CourseRepository( } } - suspend fun getCourseProgress(courseId: String): CourseProgress { - val response = api.getCourseProgress(courseId) - return response.mapToDomain() - } + fun getCourseProgress(courseId: String, isRefresh: Boolean): Flow = + channelFlowWithAwait { + if (!isRefresh) { + val cached = courseDao.getCourseProgressById(courseId) + if (cached != null) { + trySend(cached.mapToDomain()) + } + } + val response = api.getCourseProgress(courseId) + courseDao.insertCourseProgressEntity(response.mapToRoomEntity(courseId)) + trySend(response.mapToDomain()) + } } diff --git a/course/src/main/java/org/openedx/course/data/storage/CourseConverter.kt b/course/src/main/java/org/openedx/course/data/storage/CourseConverter.kt index 8daa7fb13..c59b69638 100644 --- a/course/src/main/java/org/openedx/course/data/storage/CourseConverter.kt +++ b/course/src/main/java/org/openedx/course/data/storage/CourseConverter.kt @@ -1,27 +1,16 @@ package org.openedx.course.data.storage import androidx.room.TypeConverter +import com.google.common.reflect.TypeToken import com.google.gson.Gson import org.openedx.core.data.model.room.BlockDb -import org.openedx.core.data.model.room.VideoInfoDb +import org.openedx.core.data.model.room.GradingPolicyDb +import org.openedx.core.data.model.room.SectionScoreDb import org.openedx.core.data.model.room.discovery.CourseDateBlockDb import org.openedx.foundation.extension.genericType class CourseConverter { - @TypeConverter - fun fromVideoDb(value: VideoInfoDb?): String { - if (value == null) return "" - val json = Gson().toJson(value) - return json.toString() - } - - @TypeConverter - fun toVideoDb(value: String): VideoInfoDb? { - if (value.isEmpty()) return null - return Gson().fromJson(value, VideoInfoDb::class.java) - } - @TypeConverter fun fromListOfString(value: List): String { val json = Gson().toJson(value) @@ -46,18 +35,6 @@ class CourseConverter { return Gson().fromJson(value, type) } - @TypeConverter - fun fromStringToMap(value: String?): Map { - val mapType = genericType>() - return Gson().fromJson(value, mapType) - } - - @TypeConverter - fun fromMapToString(map: Map): String { - val gson = Gson() - return gson.toJson(map) - } - @TypeConverter fun fromListOfCourseDateBlockDb(value: List): String { val json = Gson().toJson(value) @@ -69,4 +46,31 @@ class CourseConverter { val type = genericType>() return Gson().fromJson(value, type) } + + @TypeConverter + fun fromSectionScoreDbList(value: List?): String = + Gson().toJson(value) + + @TypeConverter + fun toSectionScoreDbList(value: String): List = + Gson().fromJson(value, object : TypeToken>() {}.type) + + @TypeConverter + fun fromAssignmentPolicyDbList(value: List?): String = + Gson().toJson(value) + + @TypeConverter + fun toAssignmentPolicyDbList(value: String): List = + Gson().fromJson( + value, + object : TypeToken>() {}.type + ) + + @TypeConverter + fun fromGradeRangeMap(value: Map?): String = + Gson().toJson(value) + + @TypeConverter + fun toGradeRangeMap(value: String): Map = + Gson().fromJson(value, object : TypeToken>() {}.type) } diff --git a/course/src/main/java/org/openedx/course/domain/interactor/CourseInteractor.kt b/course/src/main/java/org/openedx/course/domain/interactor/CourseInteractor.kt index 16bde1da0..49fdf0d42 100644 --- a/course/src/main/java/org/openedx/course/domain/interactor/CourseInteractor.kt +++ b/course/src/main/java/org/openedx/course/domain/interactor/CourseInteractor.kt @@ -5,7 +5,6 @@ import org.openedx.core.BlockType import org.openedx.core.domain.interactor.CourseInteractor import org.openedx.core.domain.model.Block import org.openedx.core.domain.model.CourseEnrollmentDetails -import org.openedx.core.domain.model.CourseProgress import org.openedx.core.domain.model.CourseStructure import org.openedx.course.data.repository.CourseRepository @@ -66,14 +65,19 @@ class CourseInteractor( return blocks.firstOrNull { it.descendants.contains(childId) } } - private fun addToResultBlocks(videoBlock: Block, verticalBlock: Block, resultBlocks: MutableList) { + private fun addToResultBlocks( + videoBlock: Block, + verticalBlock: Block, + resultBlocks: MutableList + ) { resultBlocks.add(videoBlock) val verticalIndex = resultBlocks.indexOfFirst { it.id == verticalBlock.id } if (verticalIndex == -1) { resultBlocks.add(verticalBlock.copy(descendants = listOf(videoBlock.id))) } else { val block = resultBlocks[verticalIndex] - resultBlocks[verticalIndex] = block.copy(descendants = block.descendants + videoBlock.id) + resultBlocks[verticalIndex] = + block.copy(descendants = block.descendants + videoBlock.id) } } @@ -116,7 +120,6 @@ class CourseInteractor( suspend fun submitOfflineXBlockProgress(blockId: String, courseId: String) = repository.submitOfflineXBlockProgress(blockId, courseId) - suspend fun getCourseProgress(courseId: String): CourseProgress { - return repository.getCourseProgress(courseId) - } + fun getCourseProgress(courseId: String, isRefresh: Boolean) = + repository.getCourseProgress(courseId, isRefresh) } diff --git a/course/src/main/java/org/openedx/course/presentation/progress/CourseProgressViewModel.kt b/course/src/main/java/org/openedx/course/presentation/progress/CourseProgressViewModel.kt index 5c7d660b4..cf9ec0e9a 100644 --- a/course/src/main/java/org/openedx/course/presentation/progress/CourseProgressViewModel.kt +++ b/course/src/main/java/org/openedx/course/presentation/progress/CourseProgressViewModel.kt @@ -1,14 +1,18 @@ package org.openedx.course.presentation.progress import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.Job import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.catch +import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.launch import org.openedx.core.system.notifier.CourseCompletionSet +import org.openedx.core.system.notifier.CourseLoading import org.openedx.core.system.notifier.CourseNotifier import org.openedx.core.system.notifier.RefreshProgress import org.openedx.course.domain.interactor.CourseInteractor @@ -29,29 +33,38 @@ class CourseProgressViewModel( val uiMessage: SharedFlow get() = _uiMessage.asSharedFlow() + private var progressJob: Job? = null + init { - loadCourseProgress() + loadCourseProgress(false) collectCourseNotifier() } - fun loadCourseProgress() { - viewModelScope.launch { - _uiState.value = CourseProgressUIState.Loading - try { - val progress = interactor.getCourseProgress(courseId) - _uiState.value = CourseProgressUIState.Data(progress) - } catch (_: Exception) { - _uiState.value = CourseProgressUIState.Error + fun loadCourseProgress(isRefresh: Boolean) { + progressJob?.cancel() + progressJob = viewModelScope.launch { + if (!isRefresh) { + _uiState.value = CourseProgressUIState.Loading } + interactor.getCourseProgress(courseId, isRefresh) + .catch { + if (_uiState.value !is CourseProgressUIState.Data) { + _uiState.value = CourseProgressUIState.Error + } + courseNotifier.send(CourseLoading(false)) + } + .collectLatest { progress -> + _uiState.value = CourseProgressUIState.Data(progress) + courseNotifier.send(CourseLoading(false)) + } } } - private fun collectCourseNotifier() { viewModelScope.launch { courseNotifier.notifier.collect { event -> when (event) { - is RefreshProgress, is CourseCompletionSet -> loadCourseProgress() + is RefreshProgress, is CourseCompletionSet -> loadCourseProgress(true) } } } From 38977a27b2021bcc53b2e4006afa78072a14a464 Mon Sep 17 00:00:00 2001 From: PavloNetrebchuk Date: Thu, 26 Jun 2025 19:02:16 +0300 Subject: [PATCH 04/10] feat: color coding --- .../org.openedx.app.room.AppDatabase/3.json | 12 ++++-- .../core/data/model/CourseProgressResponse.kt | 37 +++++++++++-------- .../data/model/room/CourseProgressEntity.kt | 19 +++++++--- .../core/domain/model/CourseProgress.kt | 11 ++++-- .../progress/CourseProgressScreen.kt | 33 ++++++++++++----- .../progress/CourseProgressViewModel.kt | 2 +- 6 files changed, 75 insertions(+), 39 deletions(-) diff --git a/app/schemas/org.openedx.app.room.AppDatabase/3.json b/app/schemas/org.openedx.app.room.AppDatabase/3.json index ca04113c5..868e67c08 100644 --- a/app/schemas/org.openedx.app.room.AppDatabase/3.json +++ b/app/schemas/org.openedx.app.room.AppDatabase/3.json @@ -2,7 +2,7 @@ "formatVersion": 1, "database": { "version": 3, - "identityHash": "635efa727d2d55fb80079fa22d5bea62", + "identityHash": "b4e899be0df5caa15bb6c52c6a16c3eb", "entities": [ { "tableName": "course_discovery_table", @@ -1008,7 +1008,7 @@ }, { "tableName": "course_progress_table", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`courseId` TEXT NOT NULL, `verifiedMode` TEXT NOT NULL, `accessExpiration` TEXT NOT NULL, `creditCourseRequirements` TEXT NOT NULL, `end` TEXT NOT NULL, `enrollmentMode` TEXT NOT NULL, `hasScheduledContent` INTEGER NOT NULL, `sectionScores` TEXT NOT NULL, `studioUrl` TEXT NOT NULL, `username` TEXT NOT NULL, `userHasPassingGrade` INTEGER NOT NULL, `disableProgressGraph` INTEGER NOT NULL, `certificate_certStatus` TEXT NOT NULL, `certificate_certWebViewUrl` TEXT NOT NULL, `certificate_downloadUrl` TEXT NOT NULL, `certificate_certificateAvailableDate` TEXT NOT NULL, `completion_completeCount` INTEGER NOT NULL, `completion_incompleteCount` INTEGER NOT NULL, `completion_lockedCount` INTEGER NOT NULL, `grade_letterGrade` TEXT NOT NULL, `grade_percent` REAL NOT NULL, `grade_isPassing` INTEGER NOT NULL, `grading_assignmentPolicies` TEXT NOT NULL, `grading_gradeRange` TEXT NOT NULL, `verification_link` TEXT NOT NULL, `verification_status` TEXT NOT NULL, `verification_statusDate` TEXT NOT NULL, PRIMARY KEY(`courseId`))", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`courseId` TEXT NOT NULL, `verifiedMode` TEXT NOT NULL, `accessExpiration` TEXT NOT NULL, `creditCourseRequirements` TEXT NOT NULL, `end` TEXT NOT NULL, `enrollmentMode` TEXT NOT NULL, `hasScheduledContent` INTEGER NOT NULL, `sectionScores` TEXT NOT NULL, `studioUrl` TEXT NOT NULL, `username` TEXT NOT NULL, `userHasPassingGrade` INTEGER NOT NULL, `disableProgressGraph` INTEGER NOT NULL, `assignment_colors` TEXT, `certificate_certStatus` TEXT NOT NULL, `certificate_certWebViewUrl` TEXT NOT NULL, `certificate_downloadUrl` TEXT NOT NULL, `certificate_certificateAvailableDate` TEXT NOT NULL, `completion_completeCount` INTEGER NOT NULL, `completion_incompleteCount` INTEGER NOT NULL, `completion_lockedCount` INTEGER NOT NULL, `grade_letterGrade` TEXT NOT NULL, `grade_percent` REAL NOT NULL, `grade_isPassing` INTEGER NOT NULL, `grading_assignmentPolicies` TEXT NOT NULL, `grading_gradeRange` TEXT NOT NULL, `verification_link` TEXT NOT NULL, `verification_status` TEXT NOT NULL, `verification_statusDate` TEXT NOT NULL, PRIMARY KEY(`courseId`))", "fields": [ { "fieldPath": "courseId", @@ -1082,6 +1082,12 @@ "affinity": "INTEGER", "notNull": true }, + { + "fieldPath": "assignmentColors", + "columnName": "assignment_colors", + "affinity": "TEXT", + "notNull": false + }, { "fieldPath": "certificateData.certStatus", "columnName": "certificate_certStatus", @@ -1186,7 +1192,7 @@ "views": [], "setupQueries": [ "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", - "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '635efa727d2d55fb80079fa22d5bea62')" + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'b4e899be0df5caa15bb6c52c6a16c3eb')" ] } } \ No newline at end of file diff --git a/core/src/main/java/org/openedx/core/data/model/CourseProgressResponse.kt b/core/src/main/java/org/openedx/core/data/model/CourseProgressResponse.kt index 75a139dbf..088378873 100644 --- a/core/src/main/java/org/openedx/core/data/model/CourseProgressResponse.kt +++ b/core/src/main/java/org/openedx/core/data/model/CourseProgressResponse.kt @@ -1,5 +1,7 @@ package org.openedx.core.data.model +import androidx.compose.ui.graphics.Color +import androidx.core.graphics.toColorInt import com.google.gson.annotations.SerializedName import org.openedx.core.data.model.room.CertificateDataDb import org.openedx.core.data.model.room.CompletionSummaryDb @@ -25,7 +27,8 @@ data class CourseProgressResponse( @SerializedName("username") val username: String?, @SerializedName("user_has_passing_grade") val userHasPassingGrade: Boolean?, @SerializedName("verification_data") val verificationData: VerificationData?, - @SerializedName("disable_progress_graph") val disableProgressGraph: Boolean? + @SerializedName("disable_progress_graph") val disableProgressGraph: Boolean?, + @SerializedName("assignment_colors") val assignmentColors: List? ) { data class CertificateData( @SerializedName("cert_status") val certStatus: String?, @@ -70,8 +73,8 @@ data class CourseProgressResponse( @SerializedName("has_graded_assignment") val hasGradedAssignment: Boolean?, @SerializedName("override") val override: String?, @SerializedName("learner_has_access") val learnerHasAccess: Boolean?, - @SerializedName("num_points_earned") val numPointsEarned: Int?, - @SerializedName("num_points_possible") val numPointsPossible: Int?, + @SerializedName("num_points_earned") val numPointsEarned: Float?, + @SerializedName("num_points_possible") val numPointsPossible: Float?, @SerializedName("percent_graded") val percentGraded: Double?, @SerializedName("problem_scores") val problemScores: List?, @SerializedName("show_correctness") val showCorrectness: String?, @@ -79,8 +82,8 @@ data class CourseProgressResponse( @SerializedName("url") val url: String? ) { data class ProblemScore( - @SerializedName("earned") val earned: Int?, - @SerializedName("possible") val possible: Int? + @SerializedName("earned") val earned: Double?, + @SerializedName("possible") val possible: Double? ) } } @@ -138,13 +141,13 @@ data class CourseProgressResponse( hasGradedAssignment = subsection.hasGradedAssignment ?: false, override = subsection.override ?: "", learnerHasAccess = subsection.learnerHasAccess ?: false, - numPointsEarned = subsection.numPointsEarned ?: 0, - numPointsPossible = subsection.numPointsPossible ?: 0, + numPointsEarned = subsection.numPointsEarned ?: 0f, + numPointsPossible = subsection.numPointsPossible ?: 0f, percentGraded = subsection.percentGraded ?: 0.0, problemScores = subsection.problemScores?.map { problemScore -> CourseProgress.SectionScore.Subsection.ProblemScore( - earned = problemScore.earned ?: 0, - possible = problemScore.possible ?: 0 + earned = problemScore.earned ?: 0.0, + possible = problemScore.possible ?: 0.0 ) } ?: emptyList(), showCorrectness = subsection.showCorrectness ?: "", @@ -162,7 +165,10 @@ data class CourseProgressResponse( status = verificationData?.status ?: "", statusDate = verificationData?.statusDate ?: "" ), - disableProgressGraph = disableProgressGraph ?: false + disableProgressGraph = disableProgressGraph ?: false, + assignmentColors = assignmentColors?.map { colorString -> + Color(colorString.toColorInt()) + } ?: listOf() ) } @@ -214,13 +220,13 @@ data class CourseProgressResponse( hasGradedAssignment = subsection.hasGradedAssignment ?: false, override = subsection.override ?: "", learnerHasAccess = subsection.learnerHasAccess ?: false, - numPointsEarned = subsection.numPointsEarned ?: 0, - numPointsPossible = subsection.numPointsPossible ?: 0, + numPointsEarned = subsection.numPointsEarned ?: 0f, + numPointsPossible = subsection.numPointsPossible ?: 0f, percentGraded = subsection.percentGraded ?: 0.0, problemScores = subsection.problemScores?.map { problemScore -> SectionScoreDb.SubsectionDb.ProblemScoreDb( - earned = problemScore.earned ?: 0, - possible = problemScore.possible ?: 0 + earned = problemScore.earned ?: 0.0, + possible = problemScore.possible ?: 0.0 ) } ?: emptyList(), showCorrectness = subsection.showCorrectness ?: "", @@ -238,7 +244,8 @@ data class CourseProgressResponse( status = verificationData?.status ?: "", statusDate = verificationData?.statusDate ?: "" ), - disableProgressGraph = disableProgressGraph ?: false + disableProgressGraph = disableProgressGraph ?: false, + assignmentColors = assignmentColors, ) } } diff --git a/core/src/main/java/org/openedx/core/data/model/room/CourseProgressEntity.kt b/core/src/main/java/org/openedx/core/data/model/room/CourseProgressEntity.kt index dca580196..abac3a930 100644 --- a/core/src/main/java/org/openedx/core/data/model/room/CourseProgressEntity.kt +++ b/core/src/main/java/org/openedx/core/data/model/room/CourseProgressEntity.kt @@ -1,5 +1,7 @@ package org.openedx.core.data.model.room +import androidx.compose.ui.graphics.Color +import androidx.core.graphics.toColorInt import androidx.room.ColumnInfo import androidx.room.Embedded import androidx.room.Entity @@ -42,7 +44,9 @@ data class CourseProgressEntity( @Embedded(prefix = "verification_") val verificationData: VerificationDataDb, @ColumnInfo("disableProgressGraph") - val disableProgressGraph: Boolean + val disableProgressGraph: Boolean, + @ColumnInfo("assignment_colors") + val assignmentColors: List? ) { fun mapToDomain(): CourseProgress { return CourseProgress( @@ -115,7 +119,10 @@ data class CourseProgressEntity( status = verificationData.status, statusDate = verificationData.statusDate ), - disableProgressGraph = disableProgressGraph + disableProgressGraph = disableProgressGraph, + assignmentColors = assignmentColors?.map { colorString -> + Color(colorString.toColorInt()) + } ?: listOf() ) } } @@ -189,9 +196,9 @@ data class SectionScoreDb( @ColumnInfo("learnerHasAccess") val learnerHasAccess: Boolean, @ColumnInfo("numPointsEarned") - val numPointsEarned: Int, + val numPointsEarned: Float, @ColumnInfo("numPointsPossible") - val numPointsPossible: Int, + val numPointsPossible: Float, @ColumnInfo("percentGraded") val percentGraded: Double, @ColumnInfo("problemScores") @@ -205,9 +212,9 @@ data class SectionScoreDb( ) { data class ProblemScoreDb( @ColumnInfo("earned") - val earned: Int, + val earned: Double, @ColumnInfo("possible") - val possible: Int + val possible: Double ) } } diff --git a/core/src/main/java/org/openedx/core/domain/model/CourseProgress.kt b/core/src/main/java/org/openedx/core/domain/model/CourseProgress.kt index 2e5358a4a..3b331e0f1 100644 --- a/core/src/main/java/org/openedx/core/domain/model/CourseProgress.kt +++ b/core/src/main/java/org/openedx/core/domain/model/CourseProgress.kt @@ -1,8 +1,11 @@ package org.openedx.core.domain.model +import androidx.compose.ui.graphics.Color + data class CourseProgress( val verifiedMode: String, val accessExpiration: String, + val assignmentColors: List, val certificateData: CertificateData, val completionSummary: CompletionSummary, val courseGrade: CourseGrade, @@ -108,8 +111,8 @@ data class CourseProgress( val hasGradedAssignment: Boolean, val override: String, val learnerHasAccess: Boolean, - val numPointsEarned: Int, - val numPointsPossible: Int, + val numPointsEarned: Float, + val numPointsPossible: Float, val percentGraded: Double, val problemScores: List, val showCorrectness: String, @@ -117,8 +120,8 @@ data class CourseProgress( val url: String ) { data class ProblemScore( - val earned: Int, - val possible: Int + val earned: Double, + val possible: Double ) } } diff --git a/course/src/main/java/org/openedx/course/presentation/progress/CourseProgressScreen.kt b/course/src/main/java/org/openedx/course/presentation/progress/CourseProgressScreen.kt index 060eaf1f0..186565f6b 100644 --- a/course/src/main/java/org/openedx/course/presentation/progress/CourseProgressScreen.kt +++ b/course/src/main/java/org/openedx/course/presentation/progress/CourseProgressScreen.kt @@ -19,7 +19,7 @@ import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.shape.CircleShape import androidx.compose.material.CircularProgressIndicator import androidx.compose.material.Divider @@ -37,6 +37,7 @@ import androidx.compose.runtime.remember 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.graphics.StrokeCap import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource @@ -134,10 +135,15 @@ private fun CourseProgressContent( item { GradeDetailsHeaderView() } - items(uiState.progress.gradingPolicy.assignmentPolicies) { policy -> + itemsIndexed(uiState.progress.gradingPolicy.assignmentPolicies) { index, policy -> AssignmentTypeRow( progress = uiState.progress, - policy = policy + policy = policy, + color = if (uiState.progress.assignmentColors.isNotEmpty()) { + uiState.progress.assignmentColors[index % uiState.progress.assignmentColors.size] + } else { + MaterialTheme.appColors.primary + } ) Divider( modifier = Modifier @@ -200,7 +206,7 @@ private fun GradeDetailsFooterView( color = MaterialTheme.appColors.textDark, ) Text( - text = "${progress.getTotalWeightPercent()}%", + text = "${progress.getTotalWeightPercent().toInt()}%", style = MaterialTheme.appTypography.labelLarge, color = MaterialTheme.appColors.primary, fontWeight = FontWeight.SemiBold @@ -235,7 +241,7 @@ private fun OverallGradeView( color = MaterialTheme.appColors.textDark, ) Text( - text = "${progress.getTotalWeightPercent()}%", + text = "${progress.getTotalWeightPercent().toInt()}%", style = MaterialTheme.appTypography.labelMedium, color = MaterialTheme.appColors.primary, fontWeight = FontWeight.SemiBold @@ -255,8 +261,14 @@ private fun OverallGradeView( ) ) { progress.gradingPolicy.assignmentPolicies.forEach { assignmentPolicy -> - // No color mapping in new model, use default - val color = MaterialTheme.appColors.primary + val assignmentColors = progress.assignmentColors + val color = if (assignmentColors.isNotEmpty()) { + assignmentColors[progress.gradingPolicy.assignmentPolicies.indexOf( + assignmentPolicy + ) % assignmentColors.size] + } else { + MaterialTheme.appColors.primary + } val weightedPercent = progress.getAssignmentWeightedGradedPercent(assignmentPolicy) if (weightedPercent > 0f) { @@ -393,7 +405,8 @@ private fun CourseCompletionView( @Composable private fun AssignmentTypeRow( progress: CourseProgress, - policy: CourseProgress.GradingPolicy.AssignmentPolicy + policy: CourseProgress.GradingPolicy.AssignmentPolicy, + color: Color ) { val earned = progress.getEarnedAssignmentProblems(policy) val possible = progress.getPossibleAssignmentProblems(policy) @@ -415,7 +428,7 @@ private fun AssignmentTypeRow( .fillMaxHeight() .width(7.dp) .background( - color = MaterialTheme.appColors.primary, + color = color, shape = CircleShape ) ) @@ -448,7 +461,7 @@ private fun AssignmentTypeRow( Text( stringResource( R.string.progress_current_and_max_weighted_graded_percent, - progress.getAssignmentWeightedGradedPercent(policy), + progress.getAssignmentWeightedGradedPercent(policy).toInt(), (policy.weight * 100).toInt() ), style = MaterialTheme.appTypography.bodyLarge, diff --git a/course/src/main/java/org/openedx/course/presentation/progress/CourseProgressViewModel.kt b/course/src/main/java/org/openedx/course/presentation/progress/CourseProgressViewModel.kt index cf9ec0e9a..bfcb817ff 100644 --- a/course/src/main/java/org/openedx/course/presentation/progress/CourseProgressViewModel.kt +++ b/course/src/main/java/org/openedx/course/presentation/progress/CourseProgressViewModel.kt @@ -47,7 +47,7 @@ class CourseProgressViewModel( _uiState.value = CourseProgressUIState.Loading } interactor.getCourseProgress(courseId, isRefresh) - .catch { + .catch { e -> if (_uiState.value !is CourseProgressUIState.Data) { _uiState.value = CourseProgressUIState.Error } From 284561e03de5142463541816235c4d372ef8b585 Mon Sep 17 00:00:00 2001 From: PavloNetrebchuk Date: Wed, 2 Jul 2025 14:24:15 +0300 Subject: [PATCH 05/10] fix: changes according demo feedback --- .../progress/CourseProgressScreen.kt | 72 ++++++++++++++----- course/src/main/res/values/strings.xml | 1 + 2 files changed, 54 insertions(+), 19 deletions(-) diff --git a/course/src/main/java/org/openedx/course/presentation/progress/CourseProgressScreen.kt b/course/src/main/java/org/openedx/course/presentation/progress/CourseProgressScreen.kt index 186565f6b..bb6fcf14b 100644 --- a/course/src/main/java/org/openedx/course/presentation/progress/CourseProgressScreen.kt +++ b/course/src/main/java/org/openedx/course/presentation/progress/CourseProgressScreen.kt @@ -7,6 +7,7 @@ import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.IntrinsicSize +import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxHeight @@ -28,6 +29,8 @@ import androidx.compose.material.MaterialTheme import androidx.compose.material.Scaffold import androidx.compose.material.Surface import androidx.compose.material.Text +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.outlined.InsertDriveFile import androidx.compose.material.rememberScaffoldState import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState @@ -44,6 +47,7 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.SpanStyle import androidx.compose.ui.text.buildAnnotatedString import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.withStyle import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp @@ -103,30 +107,27 @@ private fun CourseProgressContent( ) } - HandleUIMessage(uiMessage = uiMessage, scaffoldState = scaffoldState) - Box( - modifier = Modifier + modifier = screenWidth .fillMaxSize() + .background(MaterialTheme.appColors.background) .padding(it) .displayCutoutForLandscape(), contentAlignment = Alignment.TopCenter ) { - Surface( - modifier = screenWidth, - color = MaterialTheme.appColors.background + LazyColumn( + modifier = Modifier + .fillMaxSize() + .padding(horizontal = 16.dp), + verticalArrangement = Arrangement.spacedBy(16.dp), + contentPadding = PaddingValues(vertical = 16.dp) ) { - LazyColumn( - modifier = Modifier - .fillMaxSize() - .padding(16.dp), - verticalArrangement = Arrangement.spacedBy(16.dp) - ) { - item { - CourseCompletionView( - progress = uiState.progress - ) - } + item { + CourseCompletionView( + progress = uiState.progress + ) + } + if (uiState.progress.gradingPolicy.assignmentPolicies.isNotEmpty()) { item { OverallGradeView( progress = uiState.progress, @@ -156,12 +157,45 @@ private fun CourseProgressContent( progress = uiState.progress ) } + } else { + item { + Box( + modifier = Modifier + .fillParentMaxHeight(0.4f) + .fillMaxWidth(), + contentAlignment = Alignment.Center + ) { + NoGradesView() + } + } } } } + + HandleUIMessage(uiMessage = uiMessage, scaffoldState = scaffoldState) } } +@Composable +private fun NoGradesView() { + Column( + horizontalAlignment = Alignment.CenterHorizontally + ) { + Icon( + modifier = Modifier.size(60.dp), + imageVector = Icons.AutoMirrored.Outlined.InsertDriveFile, + contentDescription = null, + tint = MaterialTheme.appColors.divider + ) + Spacer(modifier = Modifier.height(12.dp)) + Text( + text = stringResource(R.string.course_progress_no_assignments), + style = MaterialTheme.appTypography.titleMedium, + color = MaterialTheme.appColors.textDark, + textAlign = TextAlign.Center + ) + } +} @Composable private fun GradeDetailsHeaderView() { @@ -440,8 +474,8 @@ private fun AssignmentTypeRow( Text( text = stringResource( R.string.progress_earned_possible_assignment_problems, - earned, - possible + earned.toInt(), + possible.toInt() ), style = MaterialTheme.appTypography.bodySmall, color = MaterialTheme.appColors.textDark, diff --git a/course/src/main/res/values/strings.xml b/course/src/main/res/values/strings.xml index 8eda59bf4..d896101de 100644 --- a/course/src/main/res/values/strings.xml +++ b/course/src/main/res/values/strings.xml @@ -80,4 +80,5 @@ %1$s / %2$s Complete of Grade %1$s / %2$s%% + This course does not contain graded assignments. From 034d8d5e3e49c7c0bdf7bdc0577ec4a33f67bfad Mon Sep 17 00:00:00 2001 From: PavloNetrebchuk Date: Wed, 2 Jul 2025 14:24:15 +0300 Subject: [PATCH 06/10] fix: changes according demo feedback --- .../org/openedx/core/data/api/CourseApi.kt | 1 - .../core/data/model/CourseProgressResponse.kt | 2 + .../data/model/room/CourseProgressEntity.kt | 1 + .../progress/CourseProgressScreen.kt | 82 ++++++++++++++----- course/src/main/res/values/strings.xml | 1 + 5 files changed, 64 insertions(+), 23 deletions(-) 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 e895d247b..d4fade6e2 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 @@ -108,7 +108,6 @@ interface CourseApi { @Path("username") username: String ): List - @GET("/api/course_home/progress/{course_id}") suspend fun getCourseProgress( @Path("course_id") courseId: String, diff --git a/core/src/main/java/org/openedx/core/data/model/CourseProgressResponse.kt b/core/src/main/java/org/openedx/core/data/model/CourseProgressResponse.kt index 088378873..22e40400e 100644 --- a/core/src/main/java/org/openedx/core/data/model/CourseProgressResponse.kt +++ b/core/src/main/java/org/openedx/core/data/model/CourseProgressResponse.kt @@ -94,6 +94,7 @@ data class CourseProgressResponse( @SerializedName("status_date") val statusDate: String? ) + @Suppress("LongMethod") fun mapToDomain(): CourseProgress { return CourseProgress( verifiedMode = verifiedMode ?: "", @@ -172,6 +173,7 @@ data class CourseProgressResponse( ) } + @Suppress("LongMethod, CyclomaticComplexMethod") fun mapToRoomEntity(courseId: String): CourseProgressEntity { return CourseProgressEntity( courseId = courseId, diff --git a/core/src/main/java/org/openedx/core/data/model/room/CourseProgressEntity.kt b/core/src/main/java/org/openedx/core/data/model/room/CourseProgressEntity.kt index abac3a930..ff3ca6125 100644 --- a/core/src/main/java/org/openedx/core/data/model/room/CourseProgressEntity.kt +++ b/core/src/main/java/org/openedx/core/data/model/room/CourseProgressEntity.kt @@ -48,6 +48,7 @@ data class CourseProgressEntity( @ColumnInfo("assignment_colors") val assignmentColors: List? ) { + @Suppress("LongMethod") fun mapToDomain(): CourseProgress { return CourseProgress( verifiedMode = verifiedMode, diff --git a/course/src/main/java/org/openedx/course/presentation/progress/CourseProgressScreen.kt b/course/src/main/java/org/openedx/course/presentation/progress/CourseProgressScreen.kt index 186565f6b..3329b237d 100644 --- a/course/src/main/java/org/openedx/course/presentation/progress/CourseProgressScreen.kt +++ b/course/src/main/java/org/openedx/course/presentation/progress/CourseProgressScreen.kt @@ -7,6 +7,7 @@ import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.IntrinsicSize +import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxHeight @@ -28,6 +29,8 @@ import androidx.compose.material.MaterialTheme import androidx.compose.material.Scaffold import androidx.compose.material.Surface import androidx.compose.material.Text +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.outlined.InsertDriveFile import androidx.compose.material.rememberScaffoldState import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState @@ -44,6 +47,7 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.SpanStyle import androidx.compose.ui.text.buildAnnotatedString import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.withStyle import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp @@ -61,6 +65,8 @@ import org.openedx.foundation.presentation.UIMessage import org.openedx.foundation.presentation.WindowSize import org.openedx.foundation.presentation.windowSizeValue +const val EMPTY_STATE_VIEW_HEIGHT = 0.4f + @Composable fun CourseProgressScreen( windowSize: WindowSize, @@ -103,30 +109,27 @@ private fun CourseProgressContent( ) } - HandleUIMessage(uiMessage = uiMessage, scaffoldState = scaffoldState) - Box( - modifier = Modifier + modifier = screenWidth .fillMaxSize() + .background(MaterialTheme.appColors.background) .padding(it) .displayCutoutForLandscape(), contentAlignment = Alignment.TopCenter ) { - Surface( - modifier = screenWidth, - color = MaterialTheme.appColors.background + LazyColumn( + modifier = Modifier + .fillMaxSize() + .padding(horizontal = 16.dp), + verticalArrangement = Arrangement.spacedBy(16.dp), + contentPadding = PaddingValues(vertical = 16.dp) ) { - LazyColumn( - modifier = Modifier - .fillMaxSize() - .padding(16.dp), - verticalArrangement = Arrangement.spacedBy(16.dp) - ) { - item { - CourseCompletionView( - progress = uiState.progress - ) - } + item { + CourseCompletionView( + progress = uiState.progress + ) + } + if (uiState.progress.gradingPolicy.assignmentPolicies.isNotEmpty()) { item { OverallGradeView( progress = uiState.progress, @@ -156,12 +159,45 @@ private fun CourseProgressContent( progress = uiState.progress ) } + } else { + item { + Box( + modifier = Modifier + .fillParentMaxHeight(EMPTY_STATE_VIEW_HEIGHT) + .fillMaxWidth(), + contentAlignment = Alignment.Center + ) { + NoGradesView() + } + } } } } + + HandleUIMessage(uiMessage = uiMessage, scaffoldState = scaffoldState) } } +@Composable +private fun NoGradesView() { + Column( + horizontalAlignment = Alignment.CenterHorizontally + ) { + Icon( + modifier = Modifier.size(60.dp), + imageVector = Icons.AutoMirrored.Outlined.InsertDriveFile, + contentDescription = null, + tint = MaterialTheme.appColors.divider + ) + Spacer(modifier = Modifier.height(12.dp)) + Text( + text = stringResource(R.string.course_progress_no_assignments), + style = MaterialTheme.appTypography.titleMedium, + color = MaterialTheme.appColors.textDark, + textAlign = TextAlign.Center + ) + } +} @Composable private fun GradeDetailsHeaderView() { @@ -263,9 +299,11 @@ private fun OverallGradeView( progress.gradingPolicy.assignmentPolicies.forEach { assignmentPolicy -> val assignmentColors = progress.assignmentColors val color = if (assignmentColors.isNotEmpty()) { - assignmentColors[progress.gradingPolicy.assignmentPolicies.indexOf( - assignmentPolicy - ) % assignmentColors.size] + assignmentColors[ + progress.gradingPolicy.assignmentPolicies.indexOf( + assignmentPolicy + ) % assignmentColors.size + ] } else { MaterialTheme.appColors.primary } @@ -440,8 +478,8 @@ private fun AssignmentTypeRow( Text( text = stringResource( R.string.progress_earned_possible_assignment_problems, - earned, - possible + earned.toInt(), + possible.toInt() ), style = MaterialTheme.appTypography.bodySmall, color = MaterialTheme.appColors.textDark, diff --git a/course/src/main/res/values/strings.xml b/course/src/main/res/values/strings.xml index 8eda59bf4..d896101de 100644 --- a/course/src/main/res/values/strings.xml +++ b/course/src/main/res/values/strings.xml @@ -80,4 +80,5 @@ %1$s / %2$s Complete of Grade %1$s / %2$s%% + This course does not contain graded assignments. From 924461f3544dd0dcf7effe54341a0a06e2ba3068 Mon Sep 17 00:00:00 2001 From: PavloNetrebchuk Date: Fri, 18 Jul 2025 12:39:21 +0300 Subject: [PATCH 07/10] feat: color coding --- .../org.openedx.app.room.AppDatabase/3.json | 18 ++++++++--------- .../core/data/model/CourseProgressResponse.kt | 20 +++++++++++-------- .../data/model/room/CourseProgressEntity.kt | 14 ++++++------- .../core/domain/model/CourseProgress.kt | 16 ++++++++------- .../progress/CourseProgressScreen.kt | 14 ++++++------- .../progress/CourseProgressViewModel.kt | 4 ++-- course/src/main/res/values/strings.xml | 2 +- 7 files changed, 46 insertions(+), 42 deletions(-) diff --git a/app/schemas/org.openedx.app.room.AppDatabase/3.json b/app/schemas/org.openedx.app.room.AppDatabase/3.json index 85bee0ea2..0b47d8504 100644 --- a/app/schemas/org.openedx.app.room.AppDatabase/3.json +++ b/app/schemas/org.openedx.app.room.AppDatabase/3.json @@ -2,7 +2,7 @@ "formatVersion": 1, "database": { "version": 3, - "identityHash": "d6028101c7598e8d1c5dedfcfc73d304", + "identityHash": "bcf7a22441e12e4c8b6fb332754827bf", "entities": [ { "tableName": "course_discovery_table", @@ -1008,7 +1008,7 @@ }, { "tableName": "course_progress_table", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`courseId` TEXT NOT NULL, `verifiedMode` TEXT NOT NULL, `accessExpiration` TEXT NOT NULL, `creditCourseRequirements` TEXT NOT NULL, `end` TEXT NOT NULL, `enrollmentMode` TEXT NOT NULL, `hasScheduledContent` INTEGER NOT NULL, `sectionScores` TEXT NOT NULL, `studioUrl` TEXT NOT NULL, `username` TEXT NOT NULL, `userHasPassingGrade` INTEGER NOT NULL, `disableProgressGraph` INTEGER NOT NULL, `assignment_colors` TEXT, `certificate_certStatus` TEXT, `certificate_certWebViewUrl` TEXT, `certificate_downloadUrl` TEXT, `certificate_certificateAvailableDate` TEXT, `completion_completeCount` INTEGER, `completion_incompleteCount` INTEGER, `completion_lockedCount` INTEGER, `grade_letterGrade` TEXT, `grade_percent` REAL, `grade_isPassing` INTEGER, `grading_assignmentPolicies` TEXT, `grading_gradeRange` TEXT, `verification_link` TEXT, `verification_status` TEXT, `verification_statusDate` TEXT, PRIMARY KEY(`courseId`))", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`courseId` TEXT NOT NULL, `verifiedMode` TEXT NOT NULL, `accessExpiration` TEXT NOT NULL, `creditCourseRequirements` TEXT NOT NULL, `end` TEXT NOT NULL, `enrollmentMode` TEXT NOT NULL, `hasScheduledContent` INTEGER NOT NULL, `sectionScores` TEXT NOT NULL, `studioUrl` TEXT NOT NULL, `username` TEXT NOT NULL, `userHasPassingGrade` INTEGER NOT NULL, `disableProgressGraph` INTEGER NOT NULL, `certificate_certStatus` TEXT, `certificate_certWebViewUrl` TEXT, `certificate_downloadUrl` TEXT, `certificate_certificateAvailableDate` TEXT, `completion_completeCount` INTEGER, `completion_incompleteCount` INTEGER, `completion_lockedCount` INTEGER, `grade_letterGrade` TEXT, `grade_percent` REAL, `grade_isPassing` INTEGER, `grading_assignmentPolicies` TEXT, `grading_gradeRange` TEXT, `grading_assignmentColors` TEXT, `verification_link` TEXT, `verification_status` TEXT, `verification_statusDate` TEXT, PRIMARY KEY(`courseId`))", "fields": [ { "fieldPath": "courseId", @@ -1082,12 +1082,6 @@ "affinity": "INTEGER", "notNull": true }, - { - "fieldPath": "assignmentColors", - "columnName": "assignment_colors", - "affinity": "TEXT", - "notNull": false - }, { "fieldPath": "certificateData.certStatus", "columnName": "certificate_certStatus", @@ -1160,6 +1154,12 @@ "affinity": "TEXT", "notNull": false }, + { + "fieldPath": "gradingPolicy.assignmentColors", + "columnName": "grading_assignmentColors", + "affinity": "TEXT", + "notNull": false + }, { "fieldPath": "verificationData.link", "columnName": "verification_link", @@ -1192,7 +1192,7 @@ "views": [], "setupQueries": [ "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", - "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'd6028101c7598e8d1c5dedfcfc73d304')" + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'bcf7a22441e12e4c8b6fb332754827bf')" ] } } \ No newline at end of file diff --git a/core/src/main/java/org/openedx/core/data/model/CourseProgressResponse.kt b/core/src/main/java/org/openedx/core/data/model/CourseProgressResponse.kt index fdb3a9044..fb1ab078f 100644 --- a/core/src/main/java/org/openedx/core/data/model/CourseProgressResponse.kt +++ b/core/src/main/java/org/openedx/core/data/model/CourseProgressResponse.kt @@ -29,7 +29,6 @@ data class CourseProgressResponse( @SerializedName("user_has_passing_grade") val userHasPassingGrade: Boolean?, @SerializedName("verification_data") val verificationData: VerificationData?, @SerializedName("disable_progress_graph") val disableProgressGraph: Boolean?, - @SerializedName("assignment_colors") val assignmentColors: List? ) { data class CertificateData( @SerializedName("cert_status") val certStatus: String?, @@ -90,17 +89,26 @@ data class CourseProgressResponse( data class GradingPolicy( @SerializedName("assignment_policies") val assignmentPolicies: List?, - @SerializedName("grade_range") val gradeRange: Map? + @SerializedName("grade_range") val gradeRange: Map?, + @SerializedName("assignment_colors") val assignmentColors: List? ) { + // Temporary solution. Backend will returns color list later + val defaultColors = listOf("#fe553a", "#32c0ff", "#a3ff7b", "#ff30ee") + fun mapToRoomEntity() = GradingPolicyDb( assignmentPolicies = assignmentPolicies?.map { it.mapToRoomEntity() } ?: emptyList(), - gradeRange = gradeRange ?: emptyMap() + gradeRange = gradeRange ?: emptyMap(), + assignmentColors = assignmentColors ?: defaultColors ) fun mapToDomain() = CourseProgress.GradingPolicy( assignmentPolicies = assignmentPolicies?.map { it.mapToDomain() } ?: emptyList(), - gradeRange = gradeRange ?: emptyMap() + gradeRange = gradeRange ?: emptyMap(), + assignmentColors = assignmentColors?.map { colorString -> + Color(colorString.toColorInt()) + } ?: defaultColors.map { Color(it.toColorInt()) } ) + data class AssignmentPolicy( @SerializedName("num_droppable") val numDroppable: Int?, @SerializedName("num_total") val numTotal: Int?, @@ -238,9 +246,6 @@ data class CourseProgressResponse( userHasPassingGrade = userHasPassingGrade ?: false, verificationData = verificationData?.mapToDomain(), disableProgressGraph = disableProgressGraph ?: false, - assignmentColors = assignmentColors?.map { colorString -> - Color(colorString.toColorInt()) - } ?: listOf() ) } @@ -263,7 +268,6 @@ data class CourseProgressResponse( userHasPassingGrade = userHasPassingGrade ?: false, verificationData = verificationData?.mapToRoomEntity(), disableProgressGraph = disableProgressGraph ?: false, - assignmentColors = assignmentColors, ) } } diff --git a/core/src/main/java/org/openedx/core/data/model/room/CourseProgressEntity.kt b/core/src/main/java/org/openedx/core/data/model/room/CourseProgressEntity.kt index 806a2de3e..6c98cbed2 100644 --- a/core/src/main/java/org/openedx/core/data/model/room/CourseProgressEntity.kt +++ b/core/src/main/java/org/openedx/core/data/model/room/CourseProgressEntity.kt @@ -45,8 +45,6 @@ data class CourseProgressEntity( val verificationData: VerificationDataDb?, @ColumnInfo("disableProgressGraph") val disableProgressGraph: Boolean, - @ColumnInfo("assignment_colors") - val assignmentColors: List? ) { fun mapToDomain(): CourseProgress { return CourseProgress( @@ -66,9 +64,6 @@ data class CourseProgressEntity( userHasPassingGrade = userHasPassingGrade, verificationData = verificationData?.mapToDomain(), disableProgressGraph = disableProgressGraph, - assignmentColors = assignmentColors?.map { colorString -> - Color(colorString.toColorInt()) - } ?: listOf() ) } } @@ -125,11 +120,16 @@ data class GradingPolicyDb( @ColumnInfo("assignmentPolicies") val assignmentPolicies: List, @ColumnInfo("gradeRange") - val gradeRange: Map + val gradeRange: Map, + @ColumnInfo("assignmentColors") + val assignmentColors: List ) { fun mapToDomain() = CourseProgress.GradingPolicy( assignmentPolicies = assignmentPolicies.map { it.mapToDomain() }, - gradeRange = gradeRange + gradeRange = gradeRange, + assignmentColors = assignmentColors.map { colorString -> + Color(colorString.toColorInt()) + } ) data class AssignmentPolicyDb( @ColumnInfo("numDroppable") diff --git a/core/src/main/java/org/openedx/core/domain/model/CourseProgress.kt b/core/src/main/java/org/openedx/core/domain/model/CourseProgress.kt index fb0785897..1176ae76e 100644 --- a/core/src/main/java/org/openedx/core/domain/model/CourseProgress.kt +++ b/core/src/main/java/org/openedx/core/domain/model/CourseProgress.kt @@ -5,7 +5,6 @@ import androidx.compose.ui.graphics.Color data class CourseProgress( val verifiedMode: String, val accessExpiration: String, - val assignmentColors: List, val certificateData: CertificateData?, val completionSummary: CompletionSummary?, val courseGrade: CourseGrade?, @@ -47,13 +46,15 @@ data class CourseProgress( subsection.problemScores.sumOf { it.possible } } - fun getAssignmentGradedPercent(type: String) = sectionScores - .flatMap { it.subsections } - .filter { it.assignmentType == type } - .sumOf { it.percentGraded } + fun getAssignmentGradedPercent(type: String): Float { + val assignmentSections = sectionScores + .flatMap { it.subsections } + .filter { it.assignmentType == type } + return assignmentSections.sumOf { it.percentGraded }.toFloat() / assignmentSections.size + } fun getAssignmentWeightedGradedPercent(assignmentPolicy: GradingPolicy.AssignmentPolicy): Float { - return (assignmentPolicy.weight * getAssignmentGradedPercent(assignmentPolicy.type) * 100.0).toFloat() + return (assignmentPolicy.weight * getAssignmentGradedPercent(assignmentPolicy.type) * 100f).toFloat() } fun getTotalWeightPercent() = @@ -87,7 +88,8 @@ data class CourseProgress( data class GradingPolicy( val assignmentPolicies: List, - val gradeRange: Map + val gradeRange: Map, + val assignmentColors: List, ) { data class AssignmentPolicy( val numDroppable: Int, diff --git a/course/src/main/java/org/openedx/course/presentation/progress/CourseProgressScreen.kt b/course/src/main/java/org/openedx/course/presentation/progress/CourseProgressScreen.kt index 2d58295ec..909ca3f5c 100644 --- a/course/src/main/java/org/openedx/course/presentation/progress/CourseProgressScreen.kt +++ b/course/src/main/java/org/openedx/course/presentation/progress/CourseProgressScreen.kt @@ -67,8 +67,6 @@ import org.openedx.foundation.presentation.UIMessage import org.openedx.foundation.presentation.WindowSize import org.openedx.foundation.presentation.windowSizeValue -const val EMPTY_STATE_VIEW_HEIGHT = 0.4f - @Composable fun CourseProgressScreen( windowSize: WindowSize, @@ -149,8 +147,8 @@ private fun CourseProgressContent( AssignmentTypeRow( progress = uiState.progress, policy = policy, - color = if (uiState.progress.assignmentColors.isNotEmpty()) { - uiState.progress.assignmentColors[index % uiState.progress.assignmentColors.size] + color = if (gradingPolicy.assignmentColors.isNotEmpty()) { + gradingPolicy.assignmentColors[index % gradingPolicy.assignmentColors.size] } else { MaterialTheme.appColors.primary } @@ -170,8 +168,8 @@ private fun CourseProgressContent( item { Box( modifier = Modifier - .fillParentMaxHeight(EMPTY_STATE_VIEW_HEIGHT) - .fillMaxWidth(), + .fillMaxSize() + .padding(top = 60.dp), contentAlignment = Alignment.Center ) { NoGradesView() @@ -317,7 +315,7 @@ private fun OverallGradeView( ) ) { gradingPolicy.assignmentPolicies.forEach { assignmentPolicy -> - val assignmentColors = progress.assignmentColors + val assignmentColors = gradingPolicy.assignmentColors val color = if (assignmentColors.isNotEmpty()) { assignmentColors[ gradingPolicy.assignmentPolicies.indexOf( @@ -394,7 +392,7 @@ private fun OverallGradeView( Text( text = stringResource( R.string.course_progress_required_grade_percent, - progress.requiredGradePercent + progress.requiredGradePercent.toString() ), style = MaterialTheme.appTypography.labelLarge, color = MaterialTheme.appColors.textDark, diff --git a/course/src/main/java/org/openedx/course/presentation/progress/CourseProgressViewModel.kt b/course/src/main/java/org/openedx/course/presentation/progress/CourseProgressViewModel.kt index bfcb817ff..c5c4b1f06 100644 --- a/course/src/main/java/org/openedx/course/presentation/progress/CourseProgressViewModel.kt +++ b/course/src/main/java/org/openedx/course/presentation/progress/CourseProgressViewModel.kt @@ -11,9 +11,9 @@ import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.catch import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.launch -import org.openedx.core.system.notifier.CourseCompletionSet import org.openedx.core.system.notifier.CourseLoading import org.openedx.core.system.notifier.CourseNotifier +import org.openedx.core.system.notifier.CourseStructureUpdated import org.openedx.core.system.notifier.RefreshProgress import org.openedx.course.domain.interactor.CourseInteractor import org.openedx.foundation.presentation.BaseViewModel @@ -64,7 +64,7 @@ class CourseProgressViewModel( viewModelScope.launch { courseNotifier.notifier.collect { event -> when (event) { - is RefreshProgress, is CourseCompletionSet -> loadCourseProgress(true) + is RefreshProgress, is CourseStructureUpdated -> loadCourseProgress(true) } } } diff --git a/course/src/main/res/values/strings.xml b/course/src/main/res/values/strings.xml index 596b3adbe..45a56ead3 100644 --- a/course/src/main/res/values/strings.xml +++ b/course/src/main/res/values/strings.xml @@ -41,7 +41,7 @@ Overall Grade This represents your weighted grade against the grade needed to pass this course. Current Overall Weighted Grade: - A weighted grade of %1$d%% is required to pass this course + A weighted grade of %1$s%% is required to pass this course Grade Details Assignment Type Current / Max % From 381b8b3a3cb08321aa21ffe5aef01b73baa6985d Mon Sep 17 00:00:00 2001 From: PavloNetrebchuk Date: Thu, 31 Jul 2025 13:26:52 +0300 Subject: [PATCH 08/10] feat: separator between assignment policies on the progress --- .../openedx/core/domain/model/CourseProgress.kt | 1 + .../java/org/openedx/core/ui/theme/AppColors.kt | 3 ++- .../main/java/org/openedx/core/ui/theme/Theme.kt | 6 ++++-- .../openedx/org/openedx/core/ui/theme/Colors.kt | 2 ++ .../presentation/progress/CourseProgressScreen.kt | 14 ++++++++++++-- 5 files changed, 21 insertions(+), 5 deletions(-) diff --git a/core/src/main/java/org/openedx/core/domain/model/CourseProgress.kt b/core/src/main/java/org/openedx/core/domain/model/CourseProgress.kt index 1176ae76e..537959ece 100644 --- a/core/src/main/java/org/openedx/core/domain/model/CourseProgress.kt +++ b/core/src/main/java/org/openedx/core/domain/model/CourseProgress.kt @@ -50,6 +50,7 @@ data class CourseProgress( val assignmentSections = sectionScores .flatMap { it.subsections } .filter { it.assignmentType == type } + if (assignmentSections.isEmpty()) return 0f return assignmentSections.sumOf { it.percentGraded }.toFloat() / assignmentSections.size } diff --git a/core/src/main/java/org/openedx/core/ui/theme/AppColors.kt b/core/src/main/java/org/openedx/core/ui/theme/AppColors.kt index 12da2cfce..143bfabf7 100644 --- a/core/src/main/java/org/openedx/core/ui/theme/AppColors.kt +++ b/core/src/main/java/org/openedx/core/ui/theme/AppColors.kt @@ -78,7 +78,8 @@ data class AppColors( val settingsTitleContent: Color, val progressBarColor: Color, - val progressBarBackgroundColor: Color + val progressBarBackgroundColor: Color, + val gradeProgressBarBorder: Color, ) { val primary: Color get() = material.primary val primaryVariant: Color get() = material.primaryVariant diff --git a/core/src/main/java/org/openedx/core/ui/theme/Theme.kt b/core/src/main/java/org/openedx/core/ui/theme/Theme.kt index 2ad2a4eae..c4f54ac17 100644 --- a/core/src/main/java/org/openedx/core/ui/theme/Theme.kt +++ b/core/src/main/java/org/openedx/core/ui/theme/Theme.kt @@ -96,7 +96,8 @@ private val DarkColorPalette = AppColors( settingsTitleContent = dark_settings_title_content, progressBarColor = dark_progress_bar_color, - progressBarBackgroundColor = dark_progress_bar_background_color + progressBarBackgroundColor = dark_progress_bar_background_color, + gradeProgressBarBorder = dark_grade_progress_bar_color ) private val LightColorPalette = AppColors( @@ -185,7 +186,8 @@ private val LightColorPalette = AppColors( settingsTitleContent = light_settings_title_content, progressBarColor = light_progress_bar_color, - progressBarBackgroundColor = light_progress_bar_background_color + progressBarBackgroundColor = light_progress_bar_background_color, + gradeProgressBarBorder = light_grade_progress_bar_color ) val MaterialTheme.appColors: AppColors diff --git a/core/src/openedx/org/openedx/core/ui/theme/Colors.kt b/core/src/openedx/org/openedx/core/ui/theme/Colors.kt index d2618e6b0..65c082f70 100644 --- a/core/src/openedx/org/openedx/core/ui/theme/Colors.kt +++ b/core/src/openedx/org/openedx/core/ui/theme/Colors.kt @@ -74,6 +74,7 @@ val light_course_home_back_btn_background = Color.White val light_settings_title_content = Color.White val light_progress_bar_color = light_primary val light_progress_bar_background_color = Color(0xFFCCD4E0) +val light_grade_progress_bar_color = Color.Black val dark_primary = Color(0xFF3F68F8) val dark_primary_variant = Color(0xFF3700B3) @@ -147,3 +148,4 @@ val dark_course_home_back_btn_background = Color.Black val dark_settings_title_content = Color.White val dark_progress_bar_color = light_primary val dark_progress_bar_background_color = Color(0xFF8E9BAE) +val dark_grade_progress_bar_color = Color.Transparent diff --git a/course/src/main/java/org/openedx/course/presentation/progress/CourseProgressScreen.kt b/course/src/main/java/org/openedx/course/presentation/progress/CourseProgressScreen.kt index 909ca3f5c..57b13d80b 100644 --- a/course/src/main/java/org/openedx/course/presentation/progress/CourseProgressScreen.kt +++ b/course/src/main/java/org/openedx/course/presentation/progress/CourseProgressScreen.kt @@ -310,11 +310,11 @@ private fun OverallGradeView( .clip(CircleShape) .border( width = 1.dp, - color = MaterialTheme.appColors.cardViewBackground, + color = MaterialTheme.appColors.gradeProgressBarBorder, shape = CircleShape ) ) { - gradingPolicy.assignmentPolicies.forEach { assignmentPolicy -> + gradingPolicy.assignmentPolicies.forEachIndexed { index, assignmentPolicy -> val assignmentColors = gradingPolicy.assignmentColors val color = if (assignmentColors.isNotEmpty()) { assignmentColors[ @@ -334,6 +334,16 @@ private fun OverallGradeView( .background(color) .fillMaxHeight() ) + + // Add black separator between assignment policies (except after the last one) + if (index < gradingPolicy.assignmentPolicies.size - 1) { + Box( + modifier = Modifier + .width(1.dp) + .background(Color.Black) + .fillMaxHeight() + ) + } } } if (notCompletedWeightedGradePercent > 0f) { From b970fb5c1f7d9fd831cf1012108c644a3e2f9f16 Mon Sep 17 00:00:00 2001 From: PavloNetrebchuk Date: Mon, 4 Aug 2025 17:29:54 +0300 Subject: [PATCH 09/10] feat: progressTabClickedEvent --- .../java/org/openedx/course/presentation/CourseAnalytics.kt | 4 ++++ .../presentation/container/CourseContainerViewModel.kt | 6 +++++- 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/course/src/main/java/org/openedx/course/presentation/CourseAnalytics.kt b/course/src/main/java/org/openedx/course/presentation/CourseAnalytics.kt index 0dbe660e5..0eff40583 100644 --- a/course/src/main/java/org/openedx/course/presentation/CourseAnalytics.kt +++ b/course/src/main/java/org/openedx/course/presentation/CourseAnalytics.kt @@ -66,6 +66,10 @@ enum class CourseAnalyticsEvent(val eventName: String, val biValue: String) { "Course:Handouts Tab", "edx.bi.app.course.handouts_tab" ), + PROGRESS_TAB( + "Course:Progress Tab", + "edx.bi.app.course.progress_tab" + ), ANNOUNCEMENTS( "Course:Announcements", "edx.bi.app.course.announcements" diff --git a/course/src/main/java/org/openedx/course/presentation/container/CourseContainerViewModel.kt b/course/src/main/java/org/openedx/course/presentation/container/CourseContainerViewModel.kt index 88c27e2c8..18f5f9b3c 100644 --- a/course/src/main/java/org/openedx/course/presentation/container/CourseContainerViewModel.kt +++ b/course/src/main/java/org/openedx/course/presentation/container/CourseContainerViewModel.kt @@ -335,7 +335,7 @@ class CourseContainerViewModel( CourseContainerTab.VIDEOS -> videoTabClickedEvent() CourseContainerTab.DISCUSSIONS -> discussionTabClickedEvent() CourseContainerTab.DATES -> datesTabClickedEvent() - CourseContainerTab.PROGRESS -> {} + CourseContainerTab.PROGRESS -> progressTabClickedEvent() CourseContainerTab.MORE -> moreTabClickedEvent() CourseContainerTab.OFFLINE -> {} } @@ -389,6 +389,10 @@ class CourseContainerViewModel( logCourseContainerEvent(CourseAnalyticsEvent.MORE_TAB) } + private fun progressTabClickedEvent() { + logCourseContainerEvent(CourseAnalyticsEvent.PROGRESS_TAB) + } + private fun logCourseContainerEvent(event: CourseAnalyticsEvent) { courseAnalytics.logScreenEvent( screenName = event.eventName, From 331c5e03e9c91bf4fc3f8eb26c3221c73650feed Mon Sep 17 00:00:00 2001 From: PavloNetrebchuk Date: Tue, 12 Aug 2025 16:53:58 +0300 Subject: [PATCH 10/10] feat: defaultColors changed --- .../core/data/model/CourseProgressResponse.kt | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/core/src/main/java/org/openedx/core/data/model/CourseProgressResponse.kt b/core/src/main/java/org/openedx/core/data/model/CourseProgressResponse.kt index fb1ab078f..bf31419e6 100644 --- a/core/src/main/java/org/openedx/core/data/model/CourseProgressResponse.kt +++ b/core/src/main/java/org/openedx/core/data/model/CourseProgressResponse.kt @@ -92,8 +92,18 @@ data class CourseProgressResponse( @SerializedName("grade_range") val gradeRange: Map?, @SerializedName("assignment_colors") val assignmentColors: List? ) { - // Temporary solution. Backend will returns color list later - val defaultColors = listOf("#fe553a", "#32c0ff", "#a3ff7b", "#ff30ee") + // TODO Temporary solution. Backend will returns color list later + val defaultColors = listOf( + "#D24242", + "#7B9645", + "#5A5AD8", + "#B0842C", + "#2E90C2", + "#D13F88", + "#36A17D", + "#AE5AD8", + "#3BA03B" + ) fun mapToRoomEntity() = GradingPolicyDb( assignmentPolicies = assignmentPolicies?.map { it.mapToRoomEntity() } ?: emptyList(),