From f7cfb439ed2ee6725dd06cce9c350cf805a76ca5 Mon Sep 17 00:00:00 2001 From: PavloNetrebchuk Date: Thu, 24 Jul 2025 14:54:41 +0300 Subject: [PATCH 01/17] feat: course home pager and pager indicator with navigation --- .../java/org/openedx/app/di/ScreenModule.kt | 21 + .../java/org/openedx/core/ui/PageIndicator.kt | 123 ++++ core/src/main/res/values/strings.xml | 1 + .../container/CourseContainerFragment.kt | 12 +- .../presentation/home/CourseHomePagerTab.kt | 8 + .../presentation/home/CourseHomeScreen.kt | 541 ++++++++++++++++++ .../presentation/home/CourseHomeUIState.kt | 23 + .../presentation/home/CourseHomeViewModel.kt | 511 +++++++++++++++++ .../whatsnew/presentation/ui/WhatsNewUI.kt | 109 ---- .../presentation/whatsnew/WhatsNewFragment.kt | 2 +- 10 files changed, 1240 insertions(+), 111 deletions(-) create mode 100644 core/src/main/java/org/openedx/core/ui/PageIndicator.kt create mode 100644 course/src/main/java/org/openedx/course/presentation/home/CourseHomePagerTab.kt create mode 100644 course/src/main/java/org/openedx/course/presentation/home/CourseHomeScreen.kt create mode 100644 course/src/main/java/org/openedx/course/presentation/home/CourseHomeUIState.kt create mode 100644 course/src/main/java/org/openedx/course/presentation/home/CourseHomeViewModel.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 5d8f1eb5a..ca3084c2f 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.container.CourseContainerViewModel import org.openedx.course.presentation.contenttab.ContentTabViewModel import org.openedx.course.presentation.dates.CourseDatesViewModel import org.openedx.course.presentation.handouts.HandoutsViewModel +import org.openedx.course.presentation.home.CourseHomeViewModel import org.openedx.course.presentation.offline.CourseOfflineViewModel import org.openedx.course.presentation.outline.CourseContentAllViewModel import org.openedx.course.presentation.progress.CourseProgressViewModel @@ -309,6 +310,26 @@ val screenModule = module { get(), ) } + viewModel { (courseId: String, courseTitle: String) -> + CourseHomeViewModel( + courseId, + courseTitle, + get(), + get(), + get(), + get(), + get(), + get(), + get(), + get(), + get(), + get(), + get(), + get(), + get(), + get(), + ) + } viewModel { (courseId: String) -> CourseSectionViewModel( courseId, diff --git a/core/src/main/java/org/openedx/core/ui/PageIndicator.kt b/core/src/main/java/org/openedx/core/ui/PageIndicator.kt new file mode 100644 index 000000000..8e9f4f40b --- /dev/null +++ b/core/src/main/java/org/openedx/core/ui/PageIndicator.kt @@ -0,0 +1,123 @@ +package org.openedx.core.ui + +import androidx.compose.animation.animateColorAsState +import androidx.compose.animation.core.animateDpAsState +import androidx.compose.animation.core.tween +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.size +import androidx.compose.material.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.CornerRadius +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import org.openedx.core.ui.theme.OpenEdXTheme +import org.openedx.core.ui.theme.appColors + +@Composable +fun PageIndicator( + numberOfPages: Int, + modifier: Modifier = Modifier, + selectedPage: Int = 0, + selectedColor: Color = MaterialTheme.appColors.info, + previousUnselectedColor: Color = MaterialTheme.appColors.cardViewBorder, + nextUnselectedColor: Color = MaterialTheme.appColors.textFieldBorder, + defaultRadius: Dp = 20.dp, + selectedLength: Dp = 60.dp, + space: Dp = 30.dp, + animationDurationInMillis: Int = 300, +) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(space), + modifier = modifier, + ) { + for (i in 0 until numberOfPages) { + val isSelected = i == selectedPage + val unselectedColor = + if (i < selectedPage) previousUnselectedColor else nextUnselectedColor + PageIndicatorView( + isSelected = isSelected, + selectedColor = selectedColor, + defaultColor = unselectedColor, + defaultRadius = defaultRadius, + selectedLength = selectedLength, + animationDurationInMillis = animationDurationInMillis, + ) + } + } +} + +@Composable +fun PageIndicatorView( + isSelected: Boolean, + selectedColor: Color, + defaultColor: Color, + defaultRadius: Dp, + selectedLength: Dp, + animationDurationInMillis: Int, + modifier: Modifier = Modifier, +) { + val color: Color by animateColorAsState( + targetValue = if (isSelected) { + selectedColor + } else { + defaultColor + }, + animationSpec = tween( + durationMillis = animationDurationInMillis, + ), + label = "" + ) + val width: Dp by animateDpAsState( + targetValue = if (isSelected) { + selectedLength + } else { + defaultRadius + }, + animationSpec = tween( + durationMillis = animationDurationInMillis, + ), + label = "" + ) + + Canvas( + modifier = modifier + .size( + width = width, + height = defaultRadius, + ), + ) { + drawRoundRect( + color = color, + topLeft = Offset.Zero, + size = Size( + width = width.toPx(), + height = defaultRadius.toPx(), + ), + cornerRadius = CornerRadius( + x = defaultRadius.toPx(), + y = defaultRadius.toPx(), + ), + ) + } +} + +@Preview +@Composable +private fun PageIndicatorViewPreview() { + OpenEdXTheme { + PageIndicator( + numberOfPages = 4, + selectedPage = 2 + ) + } +} diff --git a/core/src/main/res/values/strings.xml b/core/src/main/res/values/strings.xml index 405751cf8..f4fabd553 100644 --- a/core/src/main/res/values/strings.xml +++ b/core/src/main/res/values/strings.xml @@ -176,6 +176,7 @@ Not Synced Syncing to calendar… Next + Previous Downloads (Untitled) 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 6280cd2fb..bf43b5452 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 @@ -90,6 +90,7 @@ import org.openedx.course.presentation.contenttab.ContentTabScreen import org.openedx.course.presentation.dates.CourseDatesScreen import org.openedx.course.presentation.handouts.HandoutsScreen import org.openedx.course.presentation.handouts.HandoutsType +import org.openedx.course.presentation.home.CourseHomeScreen import org.openedx.course.presentation.offline.CourseOfflineScreen import org.openedx.course.presentation.progress.CourseProgressScreen import org.openedx.course.presentation.ui.DatesShiftedSnackBar @@ -469,7 +470,16 @@ private fun DashboardPager( ) { page -> when (CourseContainerTab.entries[page]) { CourseContainerTab.HOME -> { - // Home tab content will be implemented later + CourseHomeScreen( + windowSize = windowSize, + viewModel = koinViewModel( + parameters = { parametersOf(viewModel.courseId, viewModel.courseName) } + ), + fragmentManager = fragmentManager, + onResetDatesClick = { + viewModel.onRefresh(CourseContainerTab.DATES) + } + ) } CourseContainerTab.DATES -> { diff --git a/course/src/main/java/org/openedx/course/presentation/home/CourseHomePagerTab.kt b/course/src/main/java/org/openedx/course/presentation/home/CourseHomePagerTab.kt new file mode 100644 index 000000000..668bf319d --- /dev/null +++ b/course/src/main/java/org/openedx/course/presentation/home/CourseHomePagerTab.kt @@ -0,0 +1,8 @@ +package org.openedx.course.presentation.home + +enum class CourseHomePagerTab { + COURSE_COMPLETION, + VIDEOS, + ASSIGNMENT, + GRADES +} \ No newline at end of file diff --git a/course/src/main/java/org/openedx/course/presentation/home/CourseHomeScreen.kt b/course/src/main/java/org/openedx/course/presentation/home/CourseHomeScreen.kt new file mode 100644 index 000000000..d298d9622 --- /dev/null +++ b/course/src/main/java/org/openedx/course/presentation/home/CourseHomeScreen.kt @@ -0,0 +1,541 @@ +package org.openedx.course.presentation.home + +import android.content.res.Configuration.UI_MODE_NIGHT_NO +import android.content.res.Configuration.UI_MODE_NIGHT_YES +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.pager.HorizontalPager +import androidx.compose.foundation.pager.rememberPagerState +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.Card +import androidx.compose.material.Icon +import androidx.compose.material.IconButton +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.filled.ArrowBackIos +import androidx.compose.material.icons.automirrored.filled.ArrowForwardIos +import androidx.compose.material.rememberScaffoldState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.AndroidUriHandler +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Devices +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import androidx.fragment.app.FragmentManager +import kotlinx.coroutines.launch +import org.openedx.core.BlockType +import org.openedx.core.NoContentScreenType +import org.openedx.core.domain.model.AssignmentProgress +import org.openedx.core.domain.model.Block +import org.openedx.core.domain.model.BlockCounts +import org.openedx.core.domain.model.CourseDatesBannerInfo +import org.openedx.core.domain.model.CourseStructure +import org.openedx.core.domain.model.CoursewareAccess +import org.openedx.core.domain.model.OfflineDownload +import org.openedx.core.domain.model.Progress +import org.openedx.core.presentation.course.CourseViewMode +import org.openedx.core.ui.CircularProgress +import org.openedx.core.ui.HandleUIMessage +import org.openedx.core.ui.NoContentScreen +import org.openedx.core.ui.PageIndicator +import org.openedx.core.ui.displayCutoutForLandscape +import org.openedx.core.ui.theme.OpenEdXTheme +import org.openedx.core.ui.theme.appColors +import org.openedx.core.ui.theme.appShapes +import org.openedx.course.R +import org.openedx.course.presentation.ui.CourseDatesBanner +import org.openedx.course.presentation.ui.CourseDatesBannerTablet +import org.openedx.course.presentation.ui.CourseMessage +import org.openedx.foundation.extension.takeIfNotEmpty +import org.openedx.foundation.presentation.UIMessage +import org.openedx.foundation.presentation.WindowSize +import org.openedx.foundation.presentation.WindowType +import org.openedx.foundation.presentation.windowSizeValue +import java.util.Date +import org.openedx.core.R as coreR + +@Composable +fun CourseHomeScreen( + windowSize: WindowSize, + viewModel: CourseHomeViewModel, + fragmentManager: FragmentManager, + onResetDatesClick: () -> Unit, +) { + val uiState by viewModel.uiState.collectAsState() + val uiMessage by viewModel.uiMessage.collectAsState(null) + val resumeBlockId by viewModel.resumeBlockId.collectAsState("") + val context = LocalContext.current + + LaunchedEffect(resumeBlockId) { + if (resumeBlockId.isNotEmpty()) { + viewModel.openBlock(fragmentManager, resumeBlockId) + } + } + + CourseHomeUI( + windowSize = windowSize, + uiState = uiState, + uiMessage = uiMessage, + onExpandClick = { block -> + if (viewModel.switchCourseSections(block.id)) { + viewModel.sequentialClickedEvent( + block.blockId, + block.displayName + ) + } + }, + onSubSectionClick = { subSectionBlock -> + if (viewModel.isCourseDropdownNavigationEnabled) { + viewModel.courseSubSectionUnit[subSectionBlock.id]?.let { unit -> + viewModel.logUnitDetailViewedEvent( + unit.blockId, + unit.displayName + ) + viewModel.courseRouter.navigateToCourseContainer( + fragmentManager, + courseId = viewModel.courseId, + unitId = unit.id, + mode = CourseViewMode.FULL + ) + } + } else { + viewModel.sequentialClickedEvent( + subSectionBlock.blockId, + subSectionBlock.displayName + ) + viewModel.courseRouter.navigateToCourseSubsections( + fm = fragmentManager, + courseId = viewModel.courseId, + subSectionId = subSectionBlock.id, + mode = CourseViewMode.FULL + ) + } + }, + onResumeClick = { componentId -> + viewModel.openBlock( + fragmentManager, + componentId + ) + }, + onDownloadClick = { blocksIds -> + viewModel.downloadBlocks( + blocksIds = blocksIds, + fragmentManager = fragmentManager, + ) + }, + onResetDatesClick = { + viewModel.resetCourseDatesBanner( + onResetDates = { + onResetDatesClick() + } + ) + }, + onCertificateClick = { + viewModel.viewCertificateTappedEvent() + it.takeIfNotEmpty() + ?.let { url -> AndroidUriHandler(context).openUri(url) } + } + ) +} + +@Composable +private fun CourseHomeUI( + windowSize: WindowSize, + uiState: CourseHomeUIState, + uiMessage: UIMessage?, + onExpandClick: (Block) -> Unit, + onSubSectionClick: (Block) -> Unit, + onResumeClick: (String) -> Unit, + onDownloadClick: (blockIds: List) -> Unit, + onResetDatesClick: () -> Unit, + onCertificateClick: (String) -> Unit, +) { + 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() + ) + ) + } + + val bottomPadding by remember(key1 = windowSize) { + mutableStateOf( + windowSize.windowSizeValue( + expanded = PaddingValues(bottom = 24.dp), + compact = PaddingValues(bottom = 24.dp) + ) + ) + } + + HandleUIMessage(uiMessage = uiMessage, scaffoldState = scaffoldState) + + Box( + modifier = Modifier + .fillMaxSize() + .padding(it) + .displayCutoutForLandscape(), + contentAlignment = Alignment.TopCenter + ) { + Surface( + modifier = screenWidth, + color = MaterialTheme.appColors.background + ) { + Box { + when (uiState) { + is CourseHomeUIState.CourseData -> { + if (uiState.courseStructure.blockData.isEmpty()) { + NoContentScreen(noContentScreenType = NoContentScreenType.COURSE_OUTLINE) + } else { + Column( + modifier = Modifier + .fillMaxSize() + .padding(bottomPadding) + .verticalScroll(rememberScrollState()), + ) { + if (uiState.datesBannerInfo.isBannerAvailableForDashboard()) { + Box( + modifier = Modifier + .padding(all = 8.dp) + ) { + if (windowSize.isTablet) { + CourseDatesBannerTablet( + banner = uiState.datesBannerInfo, + resetDates = onResetDatesClick, + ) + } else { + CourseDatesBanner( + banner = uiState.datesBannerInfo, + resetDates = onResetDatesClick, + ) + } + } + } + + val certificate = uiState.courseStructure.certificate + if (certificate?.isCertificateEarned() == true) { + CourseMessage( + modifier = Modifier + .fillMaxWidth() + .padding( + vertical = 12.dp, + horizontal = 24.dp + ), + icon = painterResource(R.drawable.course_ic_certificate), + message = stringResource( + R.string.course_you_earned_certificate, + uiState.courseStructure.name + ), + action = stringResource(R.string.course_view_certificate), + onActionClick = { + onCertificateClick( + certificate.certificateURL ?: "" + ) + } + ) + } + + if (uiState.resumeComponent != null) { + // Add button with onResumeClick + } + + Spacer(modifier = Modifier.height(12.dp)) + CourseHomePager( + modifier = Modifier.fillMaxSize(), + pages = CourseHomePagerTab.entries + ) { tab -> + Card( + modifier = Modifier.fillMaxWidth(), + backgroundColor = MaterialTheme.appColors.cardViewBackground, + border = BorderStroke( + 1.dp, + MaterialTheme.appColors.cardViewBorder + ), + shape = MaterialTheme.appShapes.cardShape, + elevation = 0.dp, + ) { + when (tab) { + CourseHomePagerTab.COURSE_COMPLETION -> { + Text(tab.name) + } + + else -> { + Text(tab.name) + } + } + } + } + } + } + } + + CourseHomeUIState.Error -> { + NoContentScreen(noContentScreenType = NoContentScreenType.COURSE_OUTLINE) + } + + CourseHomeUIState.Loading -> { + CircularProgress() + } + } + } + } + } + } +} + +@Composable +fun CourseHomePager( + modifier: Modifier = Modifier, + pages: List, + pageContent: @Composable (T) -> Unit +) { + val pagerState = rememberPagerState( + initialPage = 0, + pageCount = { pages.size } + ) + val coroutineScope = rememberCoroutineScope() + + Column( + horizontalAlignment = Alignment.CenterHorizontally, + modifier = modifier + ) { + HorizontalPager( + modifier = Modifier.fillMaxWidth(), + state = pagerState, + contentPadding = PaddingValues(horizontal = 16.dp), + pageSpacing = 8.dp + ) { page -> + pageContent(pages[page]) + } + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + val isPreviousPageEnabled = pagerState.currentPage > 0 + IconButton( + enabled = pagerState.currentPage > 0, + onClick = { + coroutineScope.launch { + pagerState.animateScrollToPage(pagerState.currentPage - 1) + } + } + ) { + Icon( + modifier = Modifier.size(12.dp), + imageVector = Icons.AutoMirrored.Filled.ArrowBackIos, + contentDescription = stringResource(coreR.string.core_previous), + tint = if (isPreviousPageEnabled) { + MaterialTheme.appColors.textDark + } else { + MaterialTheme.appColors.textFieldHint + } + ) + } + PageIndicator( + modifier = Modifier.padding(vertical = 16.dp), + numberOfPages = pages.size, + selectedPage = pagerState.currentPage, + defaultRadius = 8.dp, + space = 8.dp, + selectedLength = 24.dp, + ) + val isNextPageEnabled = pagerState.currentPage < pages.size - 1 + IconButton( + enabled = isNextPageEnabled, + onClick = { + coroutineScope.launch { + pagerState.animateScrollToPage(pagerState.currentPage + 1) + } + } + ) { + Icon( + modifier = Modifier.size(12.dp), + imageVector = Icons.AutoMirrored.Filled.ArrowForwardIos, + contentDescription = stringResource(coreR.string.core_next), + tint = if (isNextPageEnabled) { + MaterialTheme.appColors.textDark + } else { + MaterialTheme.appColors.textFieldHint + } + ) + } + } + } +} + +@Preview(uiMode = UI_MODE_NIGHT_NO) +@Preview(uiMode = UI_MODE_NIGHT_YES) +@Composable +private fun CourseHomeScreenPreview() { + OpenEdXTheme { + CourseHomeUI( + windowSize = WindowSize(WindowType.Compact, WindowType.Compact), + uiState = CourseHomeUIState.CourseData( + mockCourseStructure, + mapOf(), + mockChapterBlock, + "Resumed Unit", + mapOf(), + mapOf(), + mapOf(), + CourseDatesBannerInfo( + missedDeadlines = false, + missedGatedContent = false, + verifiedUpgradeLink = "", + contentTypeGatingEnabled = false, + hasEnded = false + ), + true + ), + uiMessage = null, + onExpandClick = {}, + onSubSectionClick = {}, + onResumeClick = {}, + onDownloadClick = {}, + onResetDatesClick = {}, + onCertificateClick = {}, + ) + } +} + +@Preview(uiMode = UI_MODE_NIGHT_NO, device = Devices.NEXUS_9) +@Preview(uiMode = UI_MODE_NIGHT_YES, device = Devices.NEXUS_9) +@Composable +private fun CourseHomeScreenTabletPreview() { + OpenEdXTheme { + CourseHomeUI( + windowSize = WindowSize(WindowType.Medium, WindowType.Medium), + uiState = CourseHomeUIState.CourseData( + mockCourseStructure, + mapOf(), + mockChapterBlock, + "Resumed Unit", + mapOf(), + mapOf(), + mapOf(), + CourseDatesBannerInfo( + missedDeadlines = false, + missedGatedContent = false, + verifiedUpgradeLink = "", + contentTypeGatingEnabled = false, + hasEnded = false + ), + true + ), + uiMessage = null, + onExpandClick = {}, + onSubSectionClick = {}, + onResumeClick = {}, + onDownloadClick = {}, + onResetDatesClick = {}, + onCertificateClick = {}, + ) + } +} + +private val mockAssignmentProgress = AssignmentProgress( + assignmentType = "Home", + numPointsEarned = 1f, + numPointsPossible = 3f, + shortLabel = "HM" +) +private val mockChapterBlock = Block( + id = "id", + blockId = "blockId", + lmsWebUrl = "lmsWebUrl", + legacyWebUrl = "legacyWebUrl", + studentViewUrl = "studentViewUrl", + type = BlockType.CHAPTER, + displayName = "Chapter", + graded = false, + studentViewData = null, + studentViewMultiDevice = false, + blockCounts = BlockCounts(1), + descendants = emptyList(), + descendantsType = BlockType.CHAPTER, + completion = 0.0, + containsGatedContent = false, + assignmentProgress = mockAssignmentProgress, + due = Date(), + offlineDownload = null +) +private val mockSequentialBlock = Block( + id = "id", + blockId = "blockId", + lmsWebUrl = "lmsWebUrl", + legacyWebUrl = "legacyWebUrl", + studentViewUrl = "studentViewUrl", + type = BlockType.SEQUENTIAL, + displayName = "Sequential", + graded = false, + studentViewData = null, + studentViewMultiDevice = false, + blockCounts = BlockCounts(1), + descendants = emptyList(), + descendantsType = BlockType.CHAPTER, + completion = 0.0, + containsGatedContent = false, + assignmentProgress = mockAssignmentProgress, + due = Date(), + offlineDownload = OfflineDownload("fileUrl", "", 1), +) + +private val mockCourseStructure = CourseStructure( + root = "", + blockData = listOf(mockSequentialBlock, mockSequentialBlock), + id = "id", + name = "Course name", + number = "", + org = "Org", + start = Date(), + startDisplay = "", + startType = "", + end = Date(), + coursewareAccess = CoursewareAccess( + true, + "", + "", + "", + "", + "" + ), + media = null, + certificate = null, + isSelfPaced = false, + progress = Progress(1, 3), +) diff --git a/course/src/main/java/org/openedx/course/presentation/home/CourseHomeUIState.kt b/course/src/main/java/org/openedx/course/presentation/home/CourseHomeUIState.kt new file mode 100644 index 000000000..1934ab9a9 --- /dev/null +++ b/course/src/main/java/org/openedx/course/presentation/home/CourseHomeUIState.kt @@ -0,0 +1,23 @@ +package org.openedx.course.presentation.home + +import org.openedx.core.domain.model.Block +import org.openedx.core.domain.model.CourseDatesBannerInfo +import org.openedx.core.domain.model.CourseStructure +import org.openedx.core.module.db.DownloadedState + +sealed class CourseHomeUIState { + data class CourseData( + val courseStructure: CourseStructure, + val downloadedState: Map, + val resumeComponent: Block?, + val resumeUnitTitle: String, + val courseSubSections: Map>, + val courseSectionsState: Map, + val subSectionsDownloadsCount: Map, + val datesBannerInfo: CourseDatesBannerInfo, + val useRelativeDates: Boolean, + ) : CourseHomeUIState() + + data object Error : CourseHomeUIState() + data object Loading : CourseHomeUIState() +} diff --git a/course/src/main/java/org/openedx/course/presentation/home/CourseHomeViewModel.kt b/course/src/main/java/org/openedx/course/presentation/home/CourseHomeViewModel.kt new file mode 100644 index 000000000..6a64d9f43 --- /dev/null +++ b/course/src/main/java/org/openedx/course/presentation/home/CourseHomeViewModel.kt @@ -0,0 +1,511 @@ +package org.openedx.course.presentation.home + +import androidx.fragment.app.FragmentManager +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.flow.catch +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.launch +import org.openedx.core.BlockType +import org.openedx.core.R +import org.openedx.core.config.Config +import org.openedx.core.data.storage.CorePreferences +import org.openedx.core.domain.model.Block +import org.openedx.core.domain.model.CourseComponentStatus +import org.openedx.core.domain.model.CourseDateBlock +import org.openedx.core.domain.model.CourseDatesBannerInfo +import org.openedx.core.domain.model.CourseStructure +import org.openedx.core.extension.getSequentialBlocks +import org.openedx.core.extension.getVerticalBlocks +import org.openedx.core.module.DownloadWorkerController +import org.openedx.core.module.db.DownloadDao +import org.openedx.core.module.download.BaseDownloadViewModel +import org.openedx.core.module.download.DownloadHelper +import org.openedx.core.presentation.CoreAnalytics +import org.openedx.core.presentation.course.CourseViewMode +import org.openedx.core.presentation.dialog.downloaddialog.DownloadDialogManager +import org.openedx.core.presentation.settings.calendarsync.CalendarSyncDialogType +import org.openedx.core.system.connection.NetworkConnection +import org.openedx.core.system.notifier.CalendarSyncEvent.CreateCalendarSyncEvent +import org.openedx.core.system.notifier.CourseDatesShifted +import org.openedx.core.system.notifier.CourseNotifier +import org.openedx.core.system.notifier.CourseOpenBlock +import org.openedx.core.system.notifier.CourseStructureUpdated +import org.openedx.course.domain.interactor.CourseInteractor +import org.openedx.course.presentation.CourseAnalytics +import org.openedx.course.presentation.CourseAnalyticsEvent +import org.openedx.course.presentation.CourseAnalyticsKey +import org.openedx.course.presentation.CourseRouter +import org.openedx.foundation.extension.isInternetError +import org.openedx.foundation.presentation.UIMessage +import org.openedx.foundation.system.ResourceManager +import org.openedx.foundation.utils.FileUtil +import org.openedx.course.R as courseR + +class CourseHomeViewModel( + val courseId: String, + private val courseTitle: String, + private val config: Config, + private val interactor: CourseInteractor, + private val resourceManager: ResourceManager, + private val courseNotifier: CourseNotifier, + private val networkConnection: NetworkConnection, + private val preferencesManager: CorePreferences, + private val analytics: CourseAnalytics, + private val downloadDialogManager: DownloadDialogManager, + private val fileUtil: FileUtil, + val courseRouter: CourseRouter, + coreAnalytics: CoreAnalytics, + downloadDao: DownloadDao, + workerController: DownloadWorkerController, + downloadHelper: DownloadHelper, +) : BaseDownloadViewModel( + downloadDao, + preferencesManager, + workerController, + coreAnalytics, + downloadHelper +) { + val isCourseDropdownNavigationEnabled get() = config.getCourseUIConfig().isCourseDropdownNavigationEnabled + + private val _uiState = MutableStateFlow(CourseHomeUIState.Loading) + val uiState: StateFlow + get() = _uiState.asStateFlow() + + private val _uiMessage = MutableSharedFlow() + val uiMessage: SharedFlow + get() = _uiMessage.asSharedFlow() + + private val _resumeBlockId = MutableSharedFlow() + val resumeBlockId: SharedFlow + get() = _resumeBlockId.asSharedFlow() + + private var resumeSectionBlock: Block? = null + private var resumeVerticalBlock: Block? = null + + private val isCourseExpandableSectionsEnabled get() = config.getCourseUIConfig().isCourseDropdownNavigationEnabled + + private val courseSubSections = mutableMapOf>() + private val subSectionsDownloadsCount = mutableMapOf() + val courseSubSectionUnit = mutableMapOf() + + private var isOfflineBlocksUpToDate = false + + init { + viewModelScope.launch { + courseNotifier.notifier.collect { event -> + when (event) { + is CourseStructureUpdated -> { + if (event.courseId == courseId) { + getCourseData() + } + } + + is CourseOpenBlock -> { + _resumeBlockId.emit(event.blockId) + } + } + } + } + + viewModelScope.launch { + downloadModelsStatusFlow.collect { + if (_uiState.value is CourseHomeUIState.CourseData) { + val state = _uiState.value as CourseHomeUIState.CourseData + _uiState.value = CourseHomeUIState.CourseData( + courseStructure = state.courseStructure, + downloadedState = it.toMap(), + resumeComponent = state.resumeComponent, + resumeUnitTitle = resumeVerticalBlock?.displayName ?: "", + courseSubSections = courseSubSections, + courseSectionsState = state.courseSectionsState, + subSectionsDownloadsCount = subSectionsDownloadsCount, + datesBannerInfo = state.datesBannerInfo, + useRelativeDates = preferencesManager.isRelativeDatesEnabled + ) + } + } + } + + getCourseData() + } + + override fun saveDownloadModels(folder: String, courseId: String, id: String) { + if (preferencesManager.videoSettings.wifiDownloadOnly) { + if (networkConnection.isWifiConnected()) { + super.saveDownloadModels(folder, courseId, id) + } else { + viewModelScope.launch { + _uiMessage.emit( + UIMessage.ToastMessage( + resourceManager.getString(courseR.string.course_can_download_only_with_wifi) + ) + ) + } + } + } else { + super.saveDownloadModels(folder, courseId, id) + } + } + + fun getCourseData() { + getCourseDataInternal() + } + + fun switchCourseSections(blockId: String): Boolean { + return if (_uiState.value is CourseHomeUIState.CourseData) { + val state = _uiState.value as CourseHomeUIState.CourseData + val courseSectionsState = state.courseSectionsState.toMutableMap() + courseSectionsState[blockId] = !(state.courseSectionsState[blockId] ?: false) + + _uiState.value = CourseHomeUIState.CourseData( + courseStructure = state.courseStructure, + downloadedState = state.downloadedState, + resumeComponent = state.resumeComponent, + resumeUnitTitle = resumeVerticalBlock?.displayName ?: "", + courseSubSections = courseSubSections, + courseSectionsState = courseSectionsState, + subSectionsDownloadsCount = subSectionsDownloadsCount, + datesBannerInfo = state.datesBannerInfo, + useRelativeDates = preferencesManager.isRelativeDatesEnabled + ) + + courseSectionsState[blockId] ?: false + } else { + false + } + } + + private fun getCourseDataInternal() { + viewModelScope.launch { + val courseStructureFlow = interactor.getCourseStructureFlow(courseId, false) + .catch { emit(null) } + val courseStatusFlow = interactor.getCourseStatusFlow(courseId) + val courseDatesFlow = interactor.getCourseDatesFlow(courseId) + combine( + courseStructureFlow, + courseStatusFlow, + courseDatesFlow + ) { courseStructure, courseStatus, courseDatesResult -> + Triple(courseStructure, courseStatus, courseDatesResult) + }.catch { e -> + handleCourseDataError(e) + }.collect { (courseStructure, courseStatus, courseDates) -> + if (courseStructure == null) return@collect + val blocks = courseStructure.blockData + val datesBannerInfo = courseDates.courseBanner + + checkIfCalendarOutOfDate(courseDates.datesSection.values.flatten()) + updateOutdatedOfflineXBlocks(courseStructure) + + initializeCourseData(blocks, courseStructure, courseStatus, datesBannerInfo) + } + } + } + + private suspend fun initializeCourseData( + blocks: List, + courseStructure: CourseStructure, + courseStatus: CourseComponentStatus, + datesBannerInfo: CourseDatesBannerInfo + ) { + setBlocks(blocks) + courseSubSections.clear() + courseSubSectionUnit.clear() + val sortedStructure = courseStructure.copy(blockData = sortBlocks(blocks)) + initDownloadModelsStatus() + + val courseSectionsState = + (_uiState.value as? CourseHomeUIState.CourseData)?.courseSectionsState.orEmpty() + + _uiState.value = CourseHomeUIState.CourseData( + courseStructure = sortedStructure, + downloadedState = getDownloadModelsStatus(), + resumeComponent = getResumeBlock(blocks, courseStatus.lastVisitedBlockId), + resumeUnitTitle = resumeVerticalBlock?.displayName ?: "", + courseSubSections = courseSubSections, + courseSectionsState = courseSectionsState, + subSectionsDownloadsCount = subSectionsDownloadsCount, + datesBannerInfo = datesBannerInfo, + useRelativeDates = preferencesManager.isRelativeDatesEnabled + ) + } + + private suspend fun handleCourseDataError(e: Throwable?) { + _uiState.value = CourseHomeUIState.Error + val errorMessage = when { + e?.isInternetError() == true -> R.string.core_error_no_connection + else -> R.string.core_error_unknown_error + } + _uiMessage.emit(UIMessage.SnackBarMessage(resourceManager.getString(errorMessage))) + } + + private fun sortBlocks(blocks: List): List { + if (blocks.isEmpty()) return emptyList() + + val resultBlocks = mutableListOf() + blocks.forEach { block -> + if (block.type == BlockType.CHAPTER) { + resultBlocks.add(block) + processDescendants(block, blocks) + } + } + return resultBlocks + } + + private fun processDescendants(block: Block, blocks: List) { + block.descendants.forEach { descendantId -> + val sequentialBlock = blocks.find { it.id == descendantId } ?: return@forEach + addSequentialBlockToSubSections(block, sequentialBlock) + courseSubSectionUnit[sequentialBlock.id] = + sequentialBlock.getFirstDescendantBlock(blocks) + subSectionsDownloadsCount[sequentialBlock.id] = + sequentialBlock.getDownloadsCount(blocks) + addDownloadableChildrenForSequentialBlock(sequentialBlock) + } + } + + private fun addSequentialBlockToSubSections(block: Block, sequentialBlock: Block) { + courseSubSections.getOrPut(block.id) { mutableListOf() }.add(sequentialBlock) + } + + private fun getResumeBlock( + blocks: List, + continueBlockId: String, + ): Block? { + val resumeBlock = blocks.firstOrNull { it.id == continueBlockId } + resumeVerticalBlock = + blocks.getVerticalBlocks().find { it.descendants.contains(resumeBlock?.id) } + resumeSectionBlock = + blocks.getSequentialBlocks().find { it.descendants.contains(resumeVerticalBlock?.id) } + return resumeBlock + } + + fun resetCourseDatesBanner(onResetDates: (Boolean) -> Unit) { + viewModelScope.launch { + try { + interactor.resetCourseDates(courseId = courseId) + getCourseData() + courseNotifier.send(CourseDatesShifted) + onResetDates(true) + } catch (e: Exception) { + if (e.isInternetError()) { + _uiMessage.emit( + UIMessage.SnackBarMessage( + resourceManager.getString(R.string.core_error_no_connection) + ) + ) + } else { + _uiMessage.emit( + UIMessage.SnackBarMessage( + resourceManager.getString(R.string.core_dates_shift_dates_unsuccessful_msg) + ) + ) + } + onResetDates(false) + } + } + } + + fun openBlock(fragmentManager: FragmentManager, blockId: String) { + viewModelScope.launch { + val courseStructure = interactor.getCourseStructure(courseId, false) + val blocks = courseStructure.blockData + getResumeBlock(blocks, blockId) + resumeBlock(fragmentManager, blockId) + } + } + + private fun resumeBlock(fragmentManager: FragmentManager, blockId: String) { + resumeSectionBlock?.let { subSection -> + resumeCourseTappedEvent(subSection.id) + resumeVerticalBlock?.let { unit -> + if (isCourseExpandableSectionsEnabled) { + courseRouter.navigateToCourseContainer( + fm = fragmentManager, + courseId = courseId, + unitId = unit.id, + componentId = blockId, + mode = CourseViewMode.FULL + ) + } else { + courseRouter.navigateToCourseSubsections( + fragmentManager, + courseId = courseId, + subSectionId = subSection.id, + mode = CourseViewMode.FULL, + unitId = unit.id, + componentId = blockId + ) + } + } + } + } + + fun viewCertificateTappedEvent() { + analytics.logEvent( + CourseAnalyticsEvent.VIEW_CERTIFICATE.eventName, + buildMap { + put(CourseAnalyticsKey.NAME.key, CourseAnalyticsEvent.VIEW_CERTIFICATE.biValue) + put(CourseAnalyticsKey.COURSE_ID.key, courseId) + } + ) + } + + private fun resumeCourseTappedEvent(blockId: String) { + val currentState = uiState.value + if (currentState is CourseHomeUIState.CourseData) { + analytics.logEvent( + CourseAnalyticsEvent.RESUME_COURSE_CLICKED.eventName, + buildMap { + put( + CourseAnalyticsKey.NAME.key, + CourseAnalyticsEvent.RESUME_COURSE_CLICKED.biValue + ) + put(CourseAnalyticsKey.COURSE_ID.key, courseId) + put(CourseAnalyticsKey.COURSE_NAME.key, courseTitle) + put(CourseAnalyticsKey.BLOCK_ID.key, blockId) + } + ) + } + } + + fun sequentialClickedEvent(blockId: String, blockName: String) { + val currentState = uiState.value + if (currentState is CourseHomeUIState.CourseData) { + analytics.sequentialClickedEvent( + courseId, + currentState.courseStructure.name, + blockId, + blockName + ) + } + } + + fun logUnitDetailViewedEvent(blockId: String, blockName: String) { + val currentState = uiState.value + if (currentState is CourseHomeUIState.CourseData) { + analytics.logEvent( + CourseAnalyticsEvent.UNIT_DETAIL.eventName, + buildMap { + put(CourseAnalyticsKey.NAME.key, CourseAnalyticsEvent.UNIT_DETAIL.biValue) + put(CourseAnalyticsKey.COURSE_ID.key, courseId) + put(CourseAnalyticsKey.COURSE_NAME.key, courseTitle) + put(CourseAnalyticsKey.BLOCK_ID.key, blockId) + put(CourseAnalyticsKey.BLOCK_NAME.key, blockName) + } + ) + } + } + + private fun checkIfCalendarOutOfDate(courseDates: List) { + viewModelScope.launch { + courseNotifier.send( + CreateCalendarSyncEvent( + courseDates = courseDates, + dialogType = CalendarSyncDialogType.NONE.name, + checkOutOfSync = true, + ) + ) + } + } + + fun downloadBlocks(blocksIds: List, fragmentManager: FragmentManager) { + viewModelScope.launch { + val courseData = _uiState.value as? CourseHomeUIState.CourseData ?: return@launch + + val subSectionsBlocks = + courseData.courseSubSections.values.flatten().filter { it.id in blocksIds } + + val blocks = subSectionsBlocks.flatMap { subSectionsBlock -> + val verticalBlocks = + allBlocks.values.filter { it.id in subSectionsBlock.descendants } + allBlocks.values.filter { it.id in verticalBlocks.flatMap { it.descendants } } + } + + val downloadableBlocks = blocks.filter { it.isDownloadable } + val downloadingBlocks = blocksIds.filter { isBlockDownloading(it) } + val isAllBlocksDownloaded = downloadableBlocks.all { isBlockDownloaded(it.id) } + + val notDownloadedSubSectionBlocks = subSectionsBlocks.mapNotNull { subSectionsBlock -> + val verticalBlocks = + allBlocks.values.filter { it.id in subSectionsBlock.descendants } + val notDownloadedBlocks = allBlocks.values.filter { + it.id in verticalBlocks.flatMap { it.descendants } && it.isDownloadable && !isBlockDownloaded( + it.id + ) + } + if (notDownloadedBlocks.isNotEmpty()) { + subSectionsBlock + } else { + null + } + } + + val requiredSubSections = notDownloadedSubSectionBlocks.ifEmpty { + subSectionsBlocks + } + + if (downloadingBlocks.isNotEmpty()) { + val downloadableChildren = + downloadingBlocks.flatMap { getDownloadableChildren(it).orEmpty() } + if (config.getCourseUIConfig().isCourseDownloadQueueEnabled) { + courseRouter.navigateToDownloadQueue(fragmentManager, downloadableChildren) + } else { + downloadableChildren.forEach { + if (!isBlockDownloaded(it)) { + removeBlockDownloadModel(it) + } + } + } + } else { + downloadDialogManager.showPopup( + subSectionsBlocks = requiredSubSections, + courseId = courseId, + isBlocksDownloaded = isAllBlocksDownloaded, + fragmentManager = fragmentManager, + removeDownloadModels = ::removeDownloadModels, + saveDownloadModels = { blockId -> + saveDownloadModels(fileUtil.getExternalAppDir().path, courseId, blockId) + } + ) + } + } + } + + private fun updateOutdatedOfflineXBlocks(courseStructure: CourseStructure) { + viewModelScope.launch { + if (!isOfflineBlocksUpToDate) { + val xBlocks = courseStructure.blockData.filter { it.isxBlock } + if (xBlocks.isNotEmpty()) { + val xBlockIds = xBlocks.map { it.id }.toSet() + val savedDownloadModelsMap = interactor.getAllDownloadModels() + .filter { it.id in xBlockIds } + .associateBy { it.id } + + val outdatedBlockIds = xBlocks + .filter { block -> + val savedBlock = savedDownloadModelsMap[block.id] + savedBlock != null && block.offlineDownload?.lastModified != savedBlock.lastModified + } + .map { it.id } + + outdatedBlockIds.forEach { blockId -> + interactor.removeDownloadModel(blockId) + } + saveDownloadModels( + fileUtil.getExternalAppDir().path, + courseId, + outdatedBlockIds + ) + } + isOfflineBlocksUpToDate = true + } + } + } +} diff --git a/whatsnew/src/main/java/org/openedx/whatsnew/presentation/ui/WhatsNewUI.kt b/whatsnew/src/main/java/org/openedx/whatsnew/presentation/ui/WhatsNewUI.kt index d6d7c619d..2c2765619 100644 --- a/whatsnew/src/main/java/org/openedx/whatsnew/presentation/ui/WhatsNewUI.kt +++ b/whatsnew/src/main/java/org/openedx/whatsnew/presentation/ui/WhatsNewUI.kt @@ -1,21 +1,17 @@ package org.openedx.whatsnew.presentation.ui import androidx.compose.animation.AnimatedContent -import androidx.compose.animation.animateColorAsState -import androidx.compose.animation.core.animateDpAsState import androidx.compose.animation.core.animateFloatAsState import androidx.compose.animation.core.tween import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut import androidx.compose.animation.togetherWith import androidx.compose.foundation.BorderStroke -import androidx.compose.foundation.Canvas import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.material.Button import androidx.compose.material.ButtonDefaults @@ -31,15 +27,10 @@ import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.alpha -import androidx.compose.ui.geometry.CornerRadius -import androidx.compose.ui.geometry.Offset -import androidx.compose.ui.geometry.Size -import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview -import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import org.openedx.core.ui.theme.OpenEdXTheme import org.openedx.core.ui.theme.appColors @@ -47,95 +38,6 @@ import org.openedx.core.ui.theme.appShapes import org.openedx.core.ui.theme.appTypography import org.openedx.whatsnew.R -@Composable -fun PageIndicator( - numberOfPages: Int, - modifier: Modifier = Modifier, - selectedPage: Int = 0, - selectedColor: Color = MaterialTheme.appColors.info, - previousUnselectedColor: Color = MaterialTheme.appColors.cardViewBorder, - nextUnselectedColor: Color = MaterialTheme.appColors.textFieldBorder, - defaultRadius: Dp = 20.dp, - selectedLength: Dp = 60.dp, - space: Dp = 30.dp, - animationDurationInMillis: Int = 300, -) { - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(space), - modifier = modifier, - ) { - for (i in 0 until numberOfPages) { - val isSelected = i == selectedPage - val unselectedColor = - if (i < selectedPage) previousUnselectedColor else nextUnselectedColor - PageIndicatorView( - isSelected = isSelected, - selectedColor = selectedColor, - defaultColor = unselectedColor, - defaultRadius = defaultRadius, - selectedLength = selectedLength, - animationDurationInMillis = animationDurationInMillis, - ) - } - } -} - -@Composable -fun PageIndicatorView( - isSelected: Boolean, - selectedColor: Color, - defaultColor: Color, - defaultRadius: Dp, - selectedLength: Dp, - animationDurationInMillis: Int, - modifier: Modifier = Modifier, -) { - val color: Color by animateColorAsState( - targetValue = if (isSelected) { - selectedColor - } else { - defaultColor - }, - animationSpec = tween( - durationMillis = animationDurationInMillis, - ), - label = "" - ) - val width: Dp by animateDpAsState( - targetValue = if (isSelected) { - selectedLength - } else { - defaultRadius - }, - animationSpec = tween( - durationMillis = animationDurationInMillis, - ), - label = "" - ) - - Canvas( - modifier = modifier - .size( - width = width, - height = defaultRadius, - ), - ) { - drawRoundRect( - color = color, - topLeft = Offset.Zero, - size = Size( - width = width.toPx(), - height = defaultRadius.toPx(), - ), - cornerRadius = CornerRadius( - x = defaultRadius.toPx(), - y = defaultRadius.toPx(), - ), - ) - } -} - @Composable fun NavigationUnitsButtons( hasPrevPage: Boolean, @@ -304,14 +206,3 @@ private fun NavigationUnitsButtonsPrevInTheEnd() { ) } } - -@Preview -@Composable -private fun PageIndicatorViewPreview() { - OpenEdXTheme { - PageIndicator( - numberOfPages = 4, - selectedPage = 2 - ) - } -} diff --git a/whatsnew/src/main/java/org/openedx/whatsnew/presentation/whatsnew/WhatsNewFragment.kt b/whatsnew/src/main/java/org/openedx/whatsnew/presentation/whatsnew/WhatsNewFragment.kt index 0cab35466..22dc96737 100644 --- a/whatsnew/src/main/java/org/openedx/whatsnew/presentation/whatsnew/WhatsNewFragment.kt +++ b/whatsnew/src/main/java/org/openedx/whatsnew/presentation/whatsnew/WhatsNewFragment.kt @@ -56,6 +56,7 @@ import androidx.fragment.app.Fragment import kotlinx.coroutines.launch import org.koin.androidx.viewmodel.ext.android.viewModel import org.koin.core.parameter.parametersOf +import org.openedx.core.ui.PageIndicator import org.openedx.core.ui.calculateCurrentOffsetForPage import org.openedx.core.ui.statusBarsInset import org.openedx.core.ui.theme.OpenEdXTheme @@ -67,7 +68,6 @@ import org.openedx.foundation.presentation.windowSizeValue import org.openedx.whatsnew.domain.model.WhatsNewItem import org.openedx.whatsnew.domain.model.WhatsNewMessage import org.openedx.whatsnew.presentation.ui.NavigationUnitsButtons -import org.openedx.whatsnew.presentation.ui.PageIndicator import org.openedx.whatsnew.presentation.whatsnew.WhatsNewFragment.Companion.BASE_ALPHA_VALUE class WhatsNewFragment : Fragment() { From 5ac65484cd5fdb0c4f667570eff29b21073280b5 Mon Sep 17 00:00:00 2001 From: PavloNetrebchuk Date: Tue, 2 Sep 2025 18:22:25 +0300 Subject: [PATCH 02/17] feat: course completion pager tab --- core/src/main/java/org/openedx/core/Mock.kt | 84 +++++++++ .../core/system/notifier/CourseNotifier.kt | 1 + .../system/notifier/CourseProgressLoaded.kt | 3 + .../data/repository/CourseRepository.kt | 8 +- .../container/CourseContainerFragment.kt | 12 ++ .../CourseCompletionHomePagerCardContent.kt | 173 ++++++++++++++++++ .../presentation/home/CourseHomeScreen.kt | 160 +++++----------- .../presentation/home/CourseHomeUIState.kt | 4 +- .../presentation/home/CourseHomeViewModel.kt | 151 +++++++-------- .../outline/CourseContentAllScreen.kt | 144 +-------------- .../progress/CourseProgressScreen.kt | 85 +++++---- .../progress/CourseProgressViewModel.kt | 2 + .../course/presentation/ui/CourseUI.kt | 75 ++++++-- course/src/main/res/values/strings.xml | 8 + 14 files changed, 523 insertions(+), 387 deletions(-) create mode 100644 core/src/main/java/org/openedx/core/Mock.kt create mode 100644 core/src/main/java/org/openedx/core/system/notifier/CourseProgressLoaded.kt create mode 100644 course/src/main/java/org/openedx/course/presentation/home/CourseCompletionHomePagerCardContent.kt diff --git a/core/src/main/java/org/openedx/core/Mock.kt b/core/src/main/java/org/openedx/core/Mock.kt new file mode 100644 index 000000000..5c34861ee --- /dev/null +++ b/core/src/main/java/org/openedx/core/Mock.kt @@ -0,0 +1,84 @@ +package org.openedx.core + +import org.openedx.core.domain.model.AssignmentProgress +import org.openedx.core.domain.model.Block +import org.openedx.core.domain.model.BlockCounts +import org.openedx.core.domain.model.CourseStructure +import org.openedx.core.domain.model.CoursewareAccess +import org.openedx.core.domain.model.OfflineDownload +import org.openedx.core.domain.model.Progress +import java.util.Date + +object Mock { + private val mockAssignmentProgress = AssignmentProgress( + assignmentType = "Home", + numPointsEarned = 1f, + numPointsPossible = 3f, + shortLabel = "HM1" + ) + val mockChapterBlock = Block( + id = "id", + blockId = "blockId", + lmsWebUrl = "lmsWebUrl", + legacyWebUrl = "legacyWebUrl", + studentViewUrl = "studentViewUrl", + type = BlockType.CHAPTER, + displayName = "Chapter", + graded = false, + studentViewData = null, + studentViewMultiDevice = false, + blockCounts = BlockCounts(1), + descendants = emptyList(), + descendantsType = BlockType.CHAPTER, + completion = 0.0, + containsGatedContent = false, + assignmentProgress = mockAssignmentProgress, + due = Date(), + offlineDownload = null + ) + private val mockSequentialBlock = Block( + id = "id", + blockId = "blockId", + lmsWebUrl = "lmsWebUrl", + legacyWebUrl = "legacyWebUrl", + studentViewUrl = "studentViewUrl", + type = BlockType.SEQUENTIAL, + displayName = "Sequential", + graded = false, + studentViewData = null, + studentViewMultiDevice = false, + blockCounts = BlockCounts(1), + descendants = emptyList(), + descendantsType = BlockType.CHAPTER, + completion = 0.0, + containsGatedContent = false, + assignmentProgress = mockAssignmentProgress, + due = Date(), + offlineDownload = OfflineDownload("fileUrl", "", 1), + ) + + val mockCourseStructure = CourseStructure( + root = "", + blockData = listOf(mockSequentialBlock, mockSequentialBlock), + id = "id", + name = "Course name", + number = "", + org = "Org", + start = Date(), + startDisplay = "", + startType = "", + end = Date(), + coursewareAccess = CoursewareAccess( + true, + "", + "", + "", + "", + "" + ), + media = null, + certificate = null, + isSelfPaced = false, + progress = Progress(1, 3), + ) +} 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 d3dac7d42..6272ded50 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 @@ -23,4 +23,5 @@ class CourseNotifier { suspend fun send(event: RefreshDates) = channel.emit(event) suspend fun send(event: RefreshDiscussions) = channel.emit(event) suspend fun send(event: RefreshProgress) = channel.emit(event) + suspend fun send(event: CourseProgressLoaded) = channel.emit(event) } diff --git a/core/src/main/java/org/openedx/core/system/notifier/CourseProgressLoaded.kt b/core/src/main/java/org/openedx/core/system/notifier/CourseProgressLoaded.kt new file mode 100644 index 000000000..482d9271e --- /dev/null +++ b/core/src/main/java/org/openedx/core/system/notifier/CourseProgressLoaded.kt @@ -0,0 +1,3 @@ +package org.openedx.core.system.notifier + +object CourseProgressLoaded : CourseEvent 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 2e460bfa6..03a25895f 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 @@ -264,8 +264,10 @@ class CourseRepository( trySend(cached.mapToDomain()) } } - val response = api.getCourseProgress(courseId) - courseDao.insertCourseProgressEntity(response.mapToRoomEntity(courseId)) - trySend(response.mapToDomain()) + if (networkConnection.isOnline()) { + val response = api.getCourseProgress(courseId) + courseDao.insertCourseProgressEntity(response.mapToRoomEntity(courseId)) + trySend(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 bf43b5452..70f0d9d20 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 @@ -478,6 +478,18 @@ private fun DashboardPager( fragmentManager = fragmentManager, onResetDatesClick = { viewModel.onRefresh(CourseContainerTab.DATES) + }, + onNavigateToContent = { contentTab -> + scope.launch { + // First scroll to CONTENT tab + pagerState.animateScrollToPage( + CourseContainerTab.entries.indexOf(CourseContainerTab.CONTENT) + ) + // Then scroll to the specified content tab + contentTabPagerState.animateScrollToPage( + CourseContentTab.entries.indexOf(contentTab) + ) + } } ) } diff --git a/course/src/main/java/org/openedx/course/presentation/home/CourseCompletionHomePagerCardContent.kt b/course/src/main/java/org/openedx/course/presentation/home/CourseCompletionHomePagerCardContent.kt new file mode 100644 index 000000000..b5f4b6e23 --- /dev/null +++ b/course/src/main/java/org/openedx/course/presentation/home/CourseCompletionHomePagerCardContent.kt @@ -0,0 +1,173 @@ +package org.openedx.course.presentation.home + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.material.Icon +import androidx.compose.material.MaterialTheme +import androidx.compose.material.Text +import androidx.compose.material.TextButton +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.List +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import org.openedx.core.Mock +import org.openedx.core.domain.model.Block +import org.openedx.core.domain.model.CourseDatesBannerInfo +import org.openedx.core.ui.theme.OpenEdXTheme +import org.openedx.core.ui.theme.appColors +import org.openedx.core.ui.theme.appTypography +import org.openedx.course.R +import org.openedx.course.presentation.progress.CourseCompletionCircularProgress +import org.openedx.course.presentation.ui.CourseSection + +@Composable +fun CourseCompletionHomePagerCardContent( + modifier: Modifier = Modifier, + uiState: CourseHomeUIState.CourseData, + onViewAllContentClick: () -> Unit, + onDownloadClick: (blockIds: List) -> Unit, + onSubSectionClick: (Block) -> Unit, +) { + val courseProgress = uiState.courseProgress?.completion ?: 0f + val courseProgressPercent = uiState.courseProgress?.completionPercent ?: 0 + + Column( + modifier = modifier + .fillMaxWidth() + .padding(16.dp) + ) { + // Title + Text( + text = stringResource(R.string.course_completion_title), + style = MaterialTheme.appTypography.titleLarge, + color = MaterialTheme.appColors.textDark + ) + + Spacer(modifier = Modifier.height(12.dp)) + + // Progress Section + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Column( + modifier = Modifier.weight(1f), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + Text( + text = stringResource(R.string.course_completion_progress_label), + style = MaterialTheme.appTypography.labelLarge, + color = MaterialTheme.appColors.textDark, + fontWeight = FontWeight.Bold + ) + Text( + text = stringResource( + R.string.course_completion_progress_description, + courseProgressPercent + ), + style = MaterialTheme.appTypography.bodyMedium, + color = MaterialTheme.appColors.textDark + ) + } + + // Circular Progress + CourseCompletionCircularProgress( + progress = courseProgress, + progressPercent = courseProgressPercent, + completedText = stringResource(R.string.course_completion_completed) + ) + } + + Spacer(modifier = Modifier.height(16.dp)) + + uiState.next?.let { (chapter, subsection) -> + // Section progress + val subSections = uiState.courseSubSections[chapter.id] + val completedCount = subSections?.count { it.isCompleted() } ?: 0 + val totalCount = subSections?.size ?: 0 + val progress = if (totalCount > 0) completedCount.toFloat() / totalCount else 0f + + CourseSection( + section = chapter, + onItemClick = {}, + isExpandable = false, + isSectionVisible = true, + useRelativeDates = uiState.useRelativeDates, + subSections = listOf(subsection), + downloadedStateMap = uiState.downloadedState, + onSubSectionClick = onSubSectionClick, + onDownloadClick = onDownloadClick, + progress = progress + ) + } + + Spacer(modifier = Modifier.height(8.dp)) + + // View All Content Button + TextButton( + onClick = onViewAllContentClick, + modifier = Modifier.align(Alignment.CenterHorizontally) + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.Center + ) { + Icon( + imageVector = Icons.AutoMirrored.Filled.List, + contentDescription = null, + modifier = Modifier.size(20.dp), + tint = MaterialTheme.appColors.primary + ) + Spacer(modifier = Modifier.width(8.dp)) + Text( + text = stringResource(R.string.course_completion_view_all_content), + style = MaterialTheme.appTypography.labelLarge, + color = MaterialTheme.appColors.primary + ) + } + } + } +} + +@Preview +@Composable +private fun CourseCompletionHomePagerCardContentPreview() { + OpenEdXTheme { + CourseCompletionHomePagerCardContent( + uiState = CourseHomeUIState.CourseData( + courseStructure = Mock.mockCourseStructure, + courseProgress = null, // No course progress for preview + next = Pair(Mock.mockChapterBlock, Mock.mockChapterBlock), // Mock next section + downloadedState = mapOf(), + resumeComponent = Mock.mockChapterBlock, + resumeUnitTitle = "Resumed Unit", + courseSubSections = mapOf(), + subSectionsDownloadsCount = mapOf(), + datesBannerInfo = CourseDatesBannerInfo( + missedDeadlines = false, + missedGatedContent = false, + verifiedUpgradeLink = "", + contentTypeGatingEnabled = false, + hasEnded = false + ), + useRelativeDates = true + ), + onViewAllContentClick = {}, + onDownloadClick = {}, + onSubSectionClick = {}, + ) + } +} diff --git a/course/src/main/java/org/openedx/course/presentation/home/CourseHomeScreen.kt b/course/src/main/java/org/openedx/course/presentation/home/CourseHomeScreen.kt index d298d9622..342f04e8d 100644 --- a/course/src/main/java/org/openedx/course/presentation/home/CourseHomeScreen.kt +++ b/course/src/main/java/org/openedx/course/presentation/home/CourseHomeScreen.kt @@ -49,16 +49,10 @@ import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import androidx.fragment.app.FragmentManager import kotlinx.coroutines.launch -import org.openedx.core.BlockType +import org.openedx.core.Mock import org.openedx.core.NoContentScreenType -import org.openedx.core.domain.model.AssignmentProgress import org.openedx.core.domain.model.Block -import org.openedx.core.domain.model.BlockCounts import org.openedx.core.domain.model.CourseDatesBannerInfo -import org.openedx.core.domain.model.CourseStructure -import org.openedx.core.domain.model.CoursewareAccess -import org.openedx.core.domain.model.OfflineDownload -import org.openedx.core.domain.model.Progress import org.openedx.core.presentation.course.CourseViewMode import org.openedx.core.ui.CircularProgress import org.openedx.core.ui.HandleUIMessage @@ -69,15 +63,16 @@ import org.openedx.core.ui.theme.OpenEdXTheme import org.openedx.core.ui.theme.appColors import org.openedx.core.ui.theme.appShapes import org.openedx.course.R +import org.openedx.course.presentation.container.CourseContentTab import org.openedx.course.presentation.ui.CourseDatesBanner import org.openedx.course.presentation.ui.CourseDatesBannerTablet import org.openedx.course.presentation.ui.CourseMessage +import org.openedx.course.presentation.ui.ResumeCourseButton import org.openedx.foundation.extension.takeIfNotEmpty import org.openedx.foundation.presentation.UIMessage import org.openedx.foundation.presentation.WindowSize import org.openedx.foundation.presentation.WindowType import org.openedx.foundation.presentation.windowSizeValue -import java.util.Date import org.openedx.core.R as coreR @Composable @@ -86,6 +81,7 @@ fun CourseHomeScreen( viewModel: CourseHomeViewModel, fragmentManager: FragmentManager, onResetDatesClick: () -> Unit, + onNavigateToContent: (CourseContentTab) -> Unit = {}, ) { val uiState by viewModel.uiState.collectAsState() val uiMessage by viewModel.uiMessage.collectAsState(null) @@ -102,14 +98,6 @@ fun CourseHomeScreen( windowSize = windowSize, uiState = uiState, uiMessage = uiMessage, - onExpandClick = { block -> - if (viewModel.switchCourseSections(block.id)) { - viewModel.sequentialClickedEvent( - block.blockId, - block.displayName - ) - } - }, onSubSectionClick = { subSectionBlock -> if (viewModel.isCourseDropdownNavigationEnabled) { viewModel.courseSubSectionUnit[subSectionBlock.id]?.let { unit -> @@ -160,7 +148,8 @@ fun CourseHomeScreen( viewModel.viewCertificateTappedEvent() it.takeIfNotEmpty() ?.let { url -> AndroidUriHandler(context).openUri(url) } - } + }, + onNavigateToContent = onNavigateToContent ) } @@ -169,12 +158,12 @@ private fun CourseHomeUI( windowSize: WindowSize, uiState: CourseHomeUIState, uiMessage: UIMessage?, - onExpandClick: (Block) -> Unit, onSubSectionClick: (Block) -> Unit, onResumeClick: (String) -> Unit, onDownloadClick: (blockIds: List) -> Unit, onResetDatesClick: () -> Unit, onCertificateClick: (String) -> Unit, + onNavigateToContent: (CourseContentTab) -> Unit, ) { val scaffoldState = rememberScaffoldState() @@ -270,7 +259,12 @@ private fun CourseHomeUI( } if (uiState.resumeComponent != null) { - // Add button with onResumeClick + ResumeCourseButton( + modifier = Modifier.padding(16.dp), + block = uiState.resumeComponent, + displayName = uiState.resumeUnitTitle, + onResumeClick = onResumeClick + ) } Spacer(modifier = Modifier.height(12.dp)) @@ -290,7 +284,14 @@ private fun CourseHomeUI( ) { when (tab) { CourseHomePagerTab.COURSE_COMPLETION -> { - Text(tab.name) + CourseCompletionHomePagerCardContent( + uiState = uiState, + onViewAllContentClick = { + onNavigateToContent(CourseContentTab.ALL) + }, + onDownloadClick = onDownloadClick, + onSubSectionClick = onSubSectionClick + ) } else -> { @@ -330,14 +331,15 @@ fun CourseHomePager( val coroutineScope = rememberCoroutineScope() Column( + modifier = modifier, horizontalAlignment = Alignment.CenterHorizontally, - modifier = modifier ) { HorizontalPager( modifier = Modifier.fillMaxWidth(), state = pagerState, contentPadding = PaddingValues(horizontal = 16.dp), - pageSpacing = 8.dp + pageSpacing = 8.dp, + beyondViewportPageCount = pages.size ) { page -> pageContent(pages[page]) } @@ -406,29 +408,30 @@ private fun CourseHomeScreenPreview() { CourseHomeUI( windowSize = WindowSize(WindowType.Compact, WindowType.Compact), uiState = CourseHomeUIState.CourseData( - mockCourseStructure, - mapOf(), - mockChapterBlock, - "Resumed Unit", - mapOf(), - mapOf(), - mapOf(), - CourseDatesBannerInfo( + courseStructure = Mock.mockCourseStructure, + courseProgress = null, // No course progress for preview + next = null, // No next section for preview + downloadedState = mapOf(), + resumeComponent = Mock.mockChapterBlock, + resumeUnitTitle = "Resumed Unit", + courseSubSections = mapOf(), + subSectionsDownloadsCount = mapOf(), + datesBannerInfo = CourseDatesBannerInfo( missedDeadlines = false, missedGatedContent = false, verifiedUpgradeLink = "", contentTypeGatingEnabled = false, hasEnded = false ), - true + useRelativeDates = true ), uiMessage = null, - onExpandClick = {}, onSubSectionClick = {}, onResumeClick = {}, onDownloadClick = {}, onResetDatesClick = {}, onCertificateClick = {}, + onNavigateToContent = { _ -> }, ) } } @@ -441,101 +444,30 @@ private fun CourseHomeScreenTabletPreview() { CourseHomeUI( windowSize = WindowSize(WindowType.Medium, WindowType.Medium), uiState = CourseHomeUIState.CourseData( - mockCourseStructure, - mapOf(), - mockChapterBlock, - "Resumed Unit", - mapOf(), - mapOf(), - mapOf(), - CourseDatesBannerInfo( + courseStructure = Mock.mockCourseStructure, + courseProgress = null, // No course progress for preview + next = null, // No next section for preview + downloadedState = mapOf(), + resumeComponent = Mock.mockChapterBlock, + resumeUnitTitle = "Resumed Unit", + courseSubSections = mapOf(), + subSectionsDownloadsCount = mapOf(), + datesBannerInfo = CourseDatesBannerInfo( missedDeadlines = false, missedGatedContent = false, verifiedUpgradeLink = "", contentTypeGatingEnabled = false, hasEnded = false ), - true + useRelativeDates = true ), uiMessage = null, - onExpandClick = {}, onSubSectionClick = {}, onResumeClick = {}, onDownloadClick = {}, onResetDatesClick = {}, onCertificateClick = {}, + onNavigateToContent = { _ -> }, ) } } - -private val mockAssignmentProgress = AssignmentProgress( - assignmentType = "Home", - numPointsEarned = 1f, - numPointsPossible = 3f, - shortLabel = "HM" -) -private val mockChapterBlock = Block( - id = "id", - blockId = "blockId", - lmsWebUrl = "lmsWebUrl", - legacyWebUrl = "legacyWebUrl", - studentViewUrl = "studentViewUrl", - type = BlockType.CHAPTER, - displayName = "Chapter", - graded = false, - studentViewData = null, - studentViewMultiDevice = false, - blockCounts = BlockCounts(1), - descendants = emptyList(), - descendantsType = BlockType.CHAPTER, - completion = 0.0, - containsGatedContent = false, - assignmentProgress = mockAssignmentProgress, - due = Date(), - offlineDownload = null -) -private val mockSequentialBlock = Block( - id = "id", - blockId = "blockId", - lmsWebUrl = "lmsWebUrl", - legacyWebUrl = "legacyWebUrl", - studentViewUrl = "studentViewUrl", - type = BlockType.SEQUENTIAL, - displayName = "Sequential", - graded = false, - studentViewData = null, - studentViewMultiDevice = false, - blockCounts = BlockCounts(1), - descendants = emptyList(), - descendantsType = BlockType.CHAPTER, - completion = 0.0, - containsGatedContent = false, - assignmentProgress = mockAssignmentProgress, - due = Date(), - offlineDownload = OfflineDownload("fileUrl", "", 1), -) - -private val mockCourseStructure = CourseStructure( - root = "", - blockData = listOf(mockSequentialBlock, mockSequentialBlock), - id = "id", - name = "Course name", - number = "", - org = "Org", - start = Date(), - startDisplay = "", - startType = "", - end = Date(), - coursewareAccess = CoursewareAccess( - true, - "", - "", - "", - "", - "" - ), - media = null, - certificate = null, - isSelfPaced = false, - progress = Progress(1, 3), -) diff --git a/course/src/main/java/org/openedx/course/presentation/home/CourseHomeUIState.kt b/course/src/main/java/org/openedx/course/presentation/home/CourseHomeUIState.kt index 1934ab9a9..743e5374d 100644 --- a/course/src/main/java/org/openedx/course/presentation/home/CourseHomeUIState.kt +++ b/course/src/main/java/org/openedx/course/presentation/home/CourseHomeUIState.kt @@ -2,17 +2,19 @@ package org.openedx.course.presentation.home import org.openedx.core.domain.model.Block import org.openedx.core.domain.model.CourseDatesBannerInfo +import org.openedx.core.domain.model.CourseProgress import org.openedx.core.domain.model.CourseStructure import org.openedx.core.module.db.DownloadedState sealed class CourseHomeUIState { data class CourseData( val courseStructure: CourseStructure, + val courseProgress: CourseProgress?, + val next: Pair?, // section and subsection, nullable val downloadedState: Map, val resumeComponent: Block?, val resumeUnitTitle: String, val courseSubSections: Map>, - val courseSectionsState: Map, val subSectionsDownloadsCount: Map, val datesBannerInfo: CourseDatesBannerInfo, val useRelativeDates: Boolean, diff --git a/course/src/main/java/org/openedx/course/presentation/home/CourseHomeViewModel.kt b/course/src/main/java/org/openedx/course/presentation/home/CourseHomeViewModel.kt index 6a64d9f43..6cd18a858 100644 --- a/course/src/main/java/org/openedx/course/presentation/home/CourseHomeViewModel.kt +++ b/course/src/main/java/org/openedx/course/presentation/home/CourseHomeViewModel.kt @@ -9,6 +9,7 @@ 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.flow.combine import kotlinx.coroutines.launch import org.openedx.core.BlockType @@ -17,9 +18,10 @@ import org.openedx.core.config.Config import org.openedx.core.data.storage.CorePreferences import org.openedx.core.domain.model.Block import org.openedx.core.domain.model.CourseComponentStatus -import org.openedx.core.domain.model.CourseDateBlock import org.openedx.core.domain.model.CourseDatesBannerInfo +import org.openedx.core.domain.model.CourseProgress import org.openedx.core.domain.model.CourseStructure +import org.openedx.core.extension.getChapterBlocks import org.openedx.core.extension.getSequentialBlocks import org.openedx.core.extension.getVerticalBlocks import org.openedx.core.module.DownloadWorkerController @@ -29,12 +31,11 @@ import org.openedx.core.module.download.DownloadHelper import org.openedx.core.presentation.CoreAnalytics import org.openedx.core.presentation.course.CourseViewMode import org.openedx.core.presentation.dialog.downloaddialog.DownloadDialogManager -import org.openedx.core.presentation.settings.calendarsync.CalendarSyncDialogType import org.openedx.core.system.connection.NetworkConnection -import org.openedx.core.system.notifier.CalendarSyncEvent.CreateCalendarSyncEvent import org.openedx.core.system.notifier.CourseDatesShifted import org.openedx.core.system.notifier.CourseNotifier import org.openedx.core.system.notifier.CourseOpenBlock +import org.openedx.core.system.notifier.CourseProgressLoaded import org.openedx.core.system.notifier.CourseStructureUpdated import org.openedx.course.domain.interactor.CourseInteractor import org.openedx.course.presentation.CourseAnalytics @@ -94,8 +95,6 @@ class CourseHomeViewModel( private val subSectionsDownloadsCount = mutableMapOf() val courseSubSectionUnit = mutableMapOf() - private var isOfflineBlocksUpToDate = false - init { viewModelScope.launch { courseNotifier.notifier.collect { event -> @@ -109,6 +108,10 @@ class CourseHomeViewModel( is CourseOpenBlock -> { _resumeBlockId.emit(event.blockId) } + + is CourseProgressLoaded -> { + getCourseProgress() + } } } } @@ -123,10 +126,11 @@ class CourseHomeViewModel( resumeComponent = state.resumeComponent, resumeUnitTitle = resumeVerticalBlock?.displayName ?: "", courseSubSections = courseSubSections, - courseSectionsState = state.courseSectionsState, subSectionsDownloadsCount = subSectionsDownloadsCount, datesBannerInfo = state.datesBannerInfo, - useRelativeDates = preferencesManager.isRelativeDatesEnabled + useRelativeDates = preferencesManager.isRelativeDatesEnabled, + next = state.next, + courseProgress = state.courseProgress ) } } @@ -157,54 +161,33 @@ class CourseHomeViewModel( getCourseDataInternal() } - fun switchCourseSections(blockId: String): Boolean { - return if (_uiState.value is CourseHomeUIState.CourseData) { - val state = _uiState.value as CourseHomeUIState.CourseData - val courseSectionsState = state.courseSectionsState.toMutableMap() - courseSectionsState[blockId] = !(state.courseSectionsState[blockId] ?: false) - - _uiState.value = CourseHomeUIState.CourseData( - courseStructure = state.courseStructure, - downloadedState = state.downloadedState, - resumeComponent = state.resumeComponent, - resumeUnitTitle = resumeVerticalBlock?.displayName ?: "", - courseSubSections = courseSubSections, - courseSectionsState = courseSectionsState, - subSectionsDownloadsCount = subSectionsDownloadsCount, - datesBannerInfo = state.datesBannerInfo, - useRelativeDates = preferencesManager.isRelativeDatesEnabled - ) - - courseSectionsState[blockId] ?: false - } else { - false - } - } - private fun getCourseDataInternal() { viewModelScope.launch { val courseStructureFlow = interactor.getCourseStructureFlow(courseId, false) .catch { emit(null) } val courseStatusFlow = interactor.getCourseStatusFlow(courseId) val courseDatesFlow = interactor.getCourseDatesFlow(courseId) + val courseProgressFlow = interactor.getCourseProgress(courseId, false) combine( courseStructureFlow, courseStatusFlow, - courseDatesFlow - ) { courseStructure, courseStatus, courseDatesResult -> - Triple(courseStructure, courseStatus, courseDatesResult) + courseDatesFlow, + courseProgressFlow + ) { courseStructure, courseStatus, courseDatesResult, courseProgress -> + if (courseStructure == null) return@combine + val blocks = courseStructure.blockData + val datesBannerInfo = courseDatesResult.courseBanner + + initializeCourseData( + blocks, + courseStructure, + courseStatus, + datesBannerInfo, + courseProgress + ) }.catch { e -> handleCourseDataError(e) - }.collect { (courseStructure, courseStatus, courseDates) -> - if (courseStructure == null) return@collect - val blocks = courseStructure.blockData - val datesBannerInfo = courseDates.courseBanner - - checkIfCalendarOutOfDate(courseDates.datesSection.values.flatten()) - updateOutdatedOfflineXBlocks(courseStructure) - - initializeCourseData(blocks, courseStructure, courseStatus, datesBannerInfo) - } + }.collect { } } } @@ -212,27 +195,27 @@ class CourseHomeViewModel( blocks: List, courseStructure: CourseStructure, courseStatus: CourseComponentStatus, - datesBannerInfo: CourseDatesBannerInfo + datesBannerInfo: CourseDatesBannerInfo, + courseProgress: CourseProgress ) { setBlocks(blocks) courseSubSections.clear() courseSubSectionUnit.clear() val sortedStructure = courseStructure.copy(blockData = sortBlocks(blocks)) initDownloadModelsStatus() - - val courseSectionsState = - (_uiState.value as? CourseHomeUIState.CourseData)?.courseSectionsState.orEmpty() + val nextSection = findFirstChapterWithIncompleteDescendants(blocks) _uiState.value = CourseHomeUIState.CourseData( courseStructure = sortedStructure, + next = nextSection, downloadedState = getDownloadModelsStatus(), resumeComponent = getResumeBlock(blocks, courseStatus.lastVisitedBlockId), resumeUnitTitle = resumeVerticalBlock?.displayName ?: "", courseSubSections = courseSubSections, - courseSectionsState = courseSectionsState, subSectionsDownloadsCount = subSectionsDownloadsCount, datesBannerInfo = datesBannerInfo, - useRelativeDates = preferencesManager.isRelativeDatesEnabled + useRelativeDates = preferencesManager.isRelativeDatesEnabled, + courseProgress = courseProgress ) } @@ -286,6 +269,26 @@ class CourseHomeViewModel( return resumeBlock } + /** + * Finds the first chapter which has incomplete descendants and returns it as a Pair + * where the first Block is the chapter and the second Block is the first incomplete subsection + */ + private fun findFirstChapterWithIncompleteDescendants(blocks: List): Pair? { + val incompleteChapterBlock = + blocks.getChapterBlocks().find { !it.isCompleted() } ?: return null + val incompleteSubsection = + findFirstIncompleteSubsection(incompleteChapterBlock, blocks) ?: return null + return Pair(incompleteChapterBlock, incompleteSubsection) + } + + private fun findFirstIncompleteSubsection(chapter: Block, blocks: List): Block? { + // Get all sequential blocks (subsections) in this chapter + val sequentialBlocks = chapter.descendants.mapNotNull { descendantId -> + blocks.find { it.id == descendantId && it.type == BlockType.SEQUENTIAL } + } + return sequentialBlocks.find { !it.isCompleted() } + } + fun resetCourseDatesBanner(onResetDates: (Boolean) -> Unit) { viewModelScope.launch { try { @@ -403,18 +406,6 @@ class CourseHomeViewModel( } } - private fun checkIfCalendarOutOfDate(courseDates: List) { - viewModelScope.launch { - courseNotifier.send( - CreateCalendarSyncEvent( - courseDates = courseDates, - dialogType = CalendarSyncDialogType.NONE.name, - checkOutOfSync = true, - ) - ) - } - } - fun downloadBlocks(blocksIds: List, fragmentManager: FragmentManager) { viewModelScope.launch { val courseData = _uiState.value as? CourseHomeUIState.CourseData ?: return@launch @@ -478,34 +469,20 @@ class CourseHomeViewModel( } } - private fun updateOutdatedOfflineXBlocks(courseStructure: CourseStructure) { + fun getCourseProgress() { viewModelScope.launch { - if (!isOfflineBlocksUpToDate) { - val xBlocks = courseStructure.blockData.filter { it.isxBlock } - if (xBlocks.isNotEmpty()) { - val xBlockIds = xBlocks.map { it.id }.toSet() - val savedDownloadModelsMap = interactor.getAllDownloadModels() - .filter { it.id in xBlockIds } - .associateBy { it.id } - - val outdatedBlockIds = xBlocks - .filter { block -> - val savedBlock = savedDownloadModelsMap[block.id] - savedBlock != null && block.offlineDownload?.lastModified != savedBlock.lastModified - } - .map { it.id } - - outdatedBlockIds.forEach { blockId -> - interactor.removeDownloadModel(blockId) + interactor.getCourseProgress(courseId, false) + .catch { e -> + if (_uiState.value !is CourseHomeUIState.CourseData) { + _uiState.value = CourseHomeUIState.Error + } + } + .collectLatest { progress -> + val currentState = _uiState.value + if (currentState is CourseHomeUIState.CourseData) { + _uiState.value = currentState.copy(courseProgress = progress) } - saveDownloadModels( - fileUtil.getExternalAppDir().path, - courseId, - outdatedBlockIds - ) } - isOfflineBlocksUpToDate = true - } } } } diff --git a/course/src/main/java/org/openedx/course/presentation/outline/CourseContentAllScreen.kt b/course/src/main/java/org/openedx/course/presentation/outline/CourseContentAllScreen.kt index 82e69dfd0..44647e3be 100644 --- a/course/src/main/java/org/openedx/course/presentation/outline/CourseContentAllScreen.kt +++ b/course/src/main/java/org/openedx/course/presentation/outline/CourseContentAllScreen.kt @@ -2,12 +2,9 @@ package org.openedx.course.presentation.outline import android.content.res.Configuration.UI_MODE_NIGHT_NO import android.content.res.Configuration.UI_MODE_NIGHT_YES -import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.PaddingValues -import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.defaultMinSize import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height @@ -17,9 +14,6 @@ import androidx.compose.foundation.lazy.LazyColumn 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.filled.ArrowForward import androidx.compose.material.rememberScaffoldState import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect @@ -34,31 +28,23 @@ import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.pluralStringResource import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.tooling.preview.Devices import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import androidx.fragment.app.FragmentManager import org.openedx.core.BlockType -import org.openedx.core.domain.model.AssignmentProgress +import org.openedx.core.Mock import org.openedx.core.domain.model.Block -import org.openedx.core.domain.model.BlockCounts import org.openedx.core.domain.model.CourseDatesBannerInfo -import org.openedx.core.domain.model.CourseStructure -import org.openedx.core.domain.model.CoursewareAccess -import org.openedx.core.domain.model.OfflineDownload import org.openedx.core.domain.model.Progress import org.openedx.core.extension.getChapterBlocks import org.openedx.core.presentation.course.CourseViewMode import org.openedx.core.ui.CircularProgress import org.openedx.core.ui.HandleUIMessage -import org.openedx.core.ui.OpenEdXButton -import org.openedx.core.ui.TextIcon import org.openedx.core.ui.displayCutoutForLandscape import org.openedx.core.ui.theme.OpenEdXTheme import org.openedx.core.ui.theme.appColors -import org.openedx.core.ui.theme.appTypography import org.openedx.course.R import org.openedx.course.presentation.contenttab.CourseContentAllEmptyState import org.openedx.course.presentation.ui.CourseDatesBanner @@ -66,12 +52,12 @@ import org.openedx.course.presentation.ui.CourseDatesBannerTablet import org.openedx.course.presentation.ui.CourseMessage import org.openedx.course.presentation.ui.CourseProgress import org.openedx.course.presentation.ui.CourseSection +import org.openedx.course.presentation.ui.ResumeCourseButton import org.openedx.foundation.extension.takeIfNotEmpty import org.openedx.foundation.presentation.UIMessage import org.openedx.foundation.presentation.WindowSize import org.openedx.foundation.presentation.WindowType import org.openedx.foundation.presentation.windowSizeValue -import java.util.Date @Composable fun CourseContentAllScreen( @@ -298,7 +284,7 @@ private fun CourseContentAllUI( if (uiState.resumeComponent != null) { item { Box(listPadding) { - ResumeCourse( + ResumeCourseButton( modifier = Modifier.padding(vertical = 16.dp), block = uiState.resumeComponent, displayName = uiState.resumeUnitTitle, @@ -320,11 +306,11 @@ private fun CourseContentAllUI( item { CourseSection( modifier = listPadding.padding(vertical = 4.dp), - block = section, + section = section, onItemClick = onExpandClick, useRelativeDates = uiState.useRelativeDates, isSectionVisible = courseSectionsState, - courseSubSections = courseSubSections, + subSections = courseSubSections, downloadedStateMap = uiState.downloadedState, onSubSectionClick = onSubSectionClick, onDownloadClick = onDownloadClick @@ -351,44 +337,6 @@ private fun CourseContentAllUI( } } -@Composable -private fun ResumeCourse( - modifier: Modifier = Modifier, - block: Block, - displayName: String, - onResumeClick: (String) -> Unit, -) { - OpenEdXButton( - modifier = modifier - .fillMaxWidth() - .defaultMinSize(minHeight = 54.dp), - onClick = { - onResumeClick(block.id) - }, - content = { - Row( - modifier = Modifier.fillMaxWidth(), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.SpaceBetween - ) { - Text( - modifier = Modifier.weight(1f), - text = displayName, - color = MaterialTheme.appColors.primaryButtonText, - style = MaterialTheme.appTypography.titleMedium, - fontWeight = FontWeight.W600 - ) - TextIcon( - text = stringResource(id = R.string.course_continue), - icon = Icons.AutoMirrored.Filled.ArrowForward, - color = MaterialTheme.appColors.primaryButtonText, - textStyle = MaterialTheme.appTypography.labelLarge - ) - } - } - ) -} - fun getUnitBlockIcon(block: Block): Int { return when (block.type) { BlockType.VIDEO -> R.drawable.course_ic_video @@ -406,9 +354,9 @@ private fun CourseOutlineScreenPreview() { CourseContentAllUI( windowSize = WindowSize(WindowType.Compact, WindowType.Compact), uiState = CourseContentAllUIState.CourseData( - mockCourseStructure, + Mock.mockCourseStructure, mapOf(), - mockChapterBlock, + Mock.mockChapterBlock, "Resumed Unit", mapOf(), mapOf(), @@ -442,9 +390,9 @@ private fun CourseContentAllScreenTabletPreview() { CourseContentAllUI( windowSize = WindowSize(WindowType.Medium, WindowType.Medium), uiState = CourseContentAllUIState.CourseData( - mockCourseStructure, + Mock.mockCourseStructure, mapOf(), - mockChapterBlock, + Mock.mockChapterBlock, "Resumed Unit", mapOf(), mapOf(), @@ -475,78 +423,6 @@ private fun CourseContentAllScreenTabletPreview() { @Composable private fun ResumeCoursePreview() { OpenEdXTheme { - ResumeCourse(block = mockChapterBlock, displayName = "Resumed Unit") {} + ResumeCourseButton(block = Mock.mockChapterBlock, displayName = "Resumed Unit") {} } } - -private val mockAssignmentProgress = AssignmentProgress( - assignmentType = "Home", - numPointsEarned = 1f, - numPointsPossible = 3f, - shortLabel = "HM1" -) -private val mockChapterBlock = Block( - id = "id", - blockId = "blockId", - lmsWebUrl = "lmsWebUrl", - legacyWebUrl = "legacyWebUrl", - studentViewUrl = "studentViewUrl", - type = BlockType.CHAPTER, - displayName = "Chapter", - graded = false, - studentViewData = null, - studentViewMultiDevice = false, - blockCounts = BlockCounts(1), - descendants = emptyList(), - descendantsType = BlockType.CHAPTER, - completion = 0.0, - containsGatedContent = false, - assignmentProgress = mockAssignmentProgress, - due = Date(), - offlineDownload = null -) -private val mockSequentialBlock = Block( - id = "id", - blockId = "blockId", - lmsWebUrl = "lmsWebUrl", - legacyWebUrl = "legacyWebUrl", - studentViewUrl = "studentViewUrl", - type = BlockType.SEQUENTIAL, - displayName = "Sequential", - graded = false, - studentViewData = null, - studentViewMultiDevice = false, - blockCounts = BlockCounts(1), - descendants = emptyList(), - descendantsType = BlockType.CHAPTER, - completion = 0.0, - containsGatedContent = false, - assignmentProgress = mockAssignmentProgress, - due = Date(), - offlineDownload = OfflineDownload("fileUrl", "", 1), -) - -private val mockCourseStructure = CourseStructure( - root = "", - blockData = listOf(mockSequentialBlock, mockSequentialBlock), - id = "id", - name = "Course name", - number = "", - org = "Org", - start = Date(), - startDisplay = "", - startType = "", - end = Date(), - coursewareAccess = CoursewareAccess( - true, - "", - "", - "", - "", - "" - ), - media = null, - certificate = null, - isSelfPaced = false, - progress = Progress(1, 3), -) 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 47a01e416..240be2521 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 @@ -434,42 +434,11 @@ private fun CourseCompletionView( color = MaterialTheme.appColors.textDark, ) } - Box( - modifier = Modifier - .align(Alignment.CenterVertically) - .semantics(mergeDescendants = true) {} - ) { - CircularProgressIndicator( - modifier = Modifier - .size(100.dp) - .border( - width = 1.dp, - color = MaterialTheme.appColors.progressBarBackgroundColor, - shape = CircleShape - ) - .padding(3.dp), - progress = progress.completion, - color = MaterialTheme.appColors.primary, - backgroundColor = MaterialTheme.appColors.progressBarBackgroundColor, - strokeWidth = 10.dp, - strokeCap = StrokeCap.Round - ) - Column( - modifier = Modifier.align(Alignment.Center), - horizontalAlignment = Alignment.CenterHorizontally - ) { - Text( - text = "${progress.completionPercent}%", - style = MaterialTheme.appTypography.headlineSmall, - color = MaterialTheme.appColors.primary, - ) - Text( - text = stringResource(R.string.course_completed), - style = MaterialTheme.appTypography.labelSmall, - color = MaterialTheme.appColors.textPrimaryVariant, - ) - } - } + CourseCompletionCircularProgress( + progress = progress.completion, + progressPercent = progress.completionPercent, + completedText = stringResource(R.string.course_completed) + ) } } @@ -545,3 +514,47 @@ private fun AssignmentTypeRow( } } } + +@Composable +fun CourseCompletionCircularProgress( + modifier: Modifier = Modifier, + progress: Float, + progressPercent: Int, + completedText: String +) { + Box( + modifier = modifier + .semantics(mergeDescendants = true) {} + ) { + CircularProgressIndicator( + modifier = Modifier + .size(100.dp) + .border( + width = 1.dp, + color = MaterialTheme.appColors.progressBarBackgroundColor, + shape = CircleShape + ) + .padding(3.dp), + progress = progress, + color = MaterialTheme.appColors.primary, + backgroundColor = MaterialTheme.appColors.progressBarBackgroundColor, + strokeWidth = 10.dp, + strokeCap = StrokeCap.Round + ) + Column( + modifier = Modifier.align(Alignment.Center), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Text( + text = "$progressPercent%", + style = MaterialTheme.appTypography.headlineSmall, + color = MaterialTheme.appColors.primary, + ) + Text( + text = completedText, + style = MaterialTheme.appTypography.labelSmall, + color = MaterialTheme.appColors.textPrimaryVariant, + ) + } + } +} 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 c5c4b1f06..87a2dacf0 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 @@ -13,6 +13,7 @@ import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.launch import org.openedx.core.system.notifier.CourseLoading import org.openedx.core.system.notifier.CourseNotifier +import org.openedx.core.system.notifier.CourseProgressLoaded import org.openedx.core.system.notifier.CourseStructureUpdated import org.openedx.core.system.notifier.RefreshProgress import org.openedx.course.domain.interactor.CourseInteractor @@ -56,6 +57,7 @@ class CourseProgressViewModel( .collectLatest { progress -> _uiState.value = CourseProgressUIState.Data(progress) courseNotifier.send(CourseLoading(false)) + courseNotifier.send(CourseProgressLoaded) } } } diff --git a/course/src/main/java/org/openedx/course/presentation/ui/CourseUI.kt b/course/src/main/java/org/openedx/course/presentation/ui/CourseUI.kt index 54075d183..c26ecb72a 100644 --- a/course/src/main/java/org/openedx/course/presentation/ui/CourseUI.kt +++ b/course/src/main/java/org/openedx/course/presentation/ui/CourseUI.kt @@ -17,6 +17,7 @@ import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.defaultMinSize import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth @@ -50,6 +51,7 @@ import androidx.compose.material.Snackbar import androidx.compose.material.Surface import androidx.compose.material.Text import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowForward import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight import androidx.compose.material.icons.filled.Close import androidx.compose.material.icons.filled.CloudDone @@ -103,6 +105,7 @@ import org.openedx.core.ui.BackBtn import org.openedx.core.ui.IconText import org.openedx.core.ui.OpenEdXButton import org.openedx.core.ui.OpenEdXOutlinedButton +import org.openedx.core.ui.TextIcon import org.openedx.core.ui.displayCutoutForLandscape import org.openedx.core.ui.noRippleClickable import org.openedx.core.ui.theme.OpenEdXTheme @@ -900,14 +903,16 @@ fun DownloadIcon( @Composable fun CourseSection( modifier: Modifier = Modifier, - block: Block, + section: Block, useRelativeDates: Boolean, + isExpandable: Boolean = true, onItemClick: (Block) -> Unit, isSectionVisible: Boolean?, - courseSubSections: List?, + subSections: List?, downloadedStateMap: Map, onSubSectionClick: (Block) -> Unit, onDownloadClick: (blocksIds: List) -> Unit, + progress: Float? = null ) { val arrowRotation by animateFloatAsState( targetValue = if (isSectionVisible == true) { @@ -917,7 +922,7 @@ fun CourseSection( }, label = "" ) - val subSectionIds = courseSubSections?.map { it.id }.orEmpty() + val subSectionIds = subSections?.map { it.id }.orEmpty() val filteredStatuses = downloadedStateMap.filterKeys { it in subSectionIds }.values val downloadedState = when { filteredStatuses.isEmpty() -> null @@ -927,14 +932,14 @@ fun CourseSection( } // Section progress - val completedCount = courseSubSections?.count { it.isCompleted() } ?: 0 - val totalCount = courseSubSections?.size ?: 0 - val progress = if (totalCount > 0) completedCount.toFloat() / totalCount else 0f + val completedCount = subSections?.count { it.isCompleted() } ?: 0 + val totalCount = subSections?.size ?: 0 + val progress = progress ?: if (totalCount > 0) completedCount.toFloat() / totalCount else 0f Column( modifier = modifier .clip(MaterialTheme.appShapes.sectionCardShape) - .noRippleClickable { onItemClick(block) } + .noRippleClickable { onItemClick(section) } .background(MaterialTheme.appColors.cardViewBackground) .border( 1.dp, @@ -951,14 +956,15 @@ fun CourseSection( backgroundColor = MaterialTheme.appColors.progressBarBackgroundColor ) CourseExpandableChapterCard( - block = block, + block = section, arrowDegrees = arrowRotation, + isExpandable = isExpandable, downloadedState = downloadedState, onDownloadClick = { - onDownloadClick(block.descendants) + onDownloadClick(section.descendants) } ) - courseSubSections?.forEach { subSectionBlock -> + subSections?.forEach { subSectionBlock -> AnimatedVisibility( visible = isSectionVisible == true ) { @@ -977,6 +983,7 @@ fun CourseExpandableChapterCard( modifier: Modifier = Modifier, block: Block, arrowDegrees: Float = 0f, + isExpandable: Boolean = true, downloadedState: DownloadedState?, onDownloadClick: () -> Unit, ) { @@ -989,7 +996,9 @@ fun CourseExpandableChapterCard( verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(4.dp) ) { - CardArrow(degrees = arrowDegrees) + if (isExpandable) { + CardArrow(degrees = arrowDegrees) + } if (block.isCompleted()) { val completedIconPainter = painterResource(R.drawable.course_ic_task_alt) val completedIconColor = MaterialTheme.appColors.successGreen @@ -1005,7 +1014,7 @@ fun CourseExpandableChapterCard( Text( modifier = Modifier.weight(1f), text = block.displayName, - style = MaterialTheme.appTypography.titleSmall, + style = MaterialTheme.appTypography.titleMedium, color = MaterialTheme.appColors.textPrimary, maxLines = 1, overflow = TextOverflow.Ellipsis @@ -1538,6 +1547,44 @@ fun CourseProgress( } } +@Composable +fun ResumeCourseButton( + modifier: Modifier = Modifier, + block: Block, + displayName: String, + onResumeClick: (String) -> Unit, +) { + OpenEdXButton( + modifier = modifier + .fillMaxWidth() + .defaultMinSize(minHeight = 54.dp), + onClick = { + onResumeClick(block.id) + }, + content = { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween + ) { + Text( + modifier = Modifier.weight(1f), + text = displayName, + color = MaterialTheme.appColors.primaryButtonText, + style = MaterialTheme.appTypography.titleMedium, + fontWeight = FontWeight.W600 + ) + TextIcon( + text = stringResource(id = R.string.course_continue), + icon = Icons.AutoMirrored.Filled.ArrowForward, + color = MaterialTheme.appColors.primaryButtonText, + textStyle = MaterialTheme.appTypography.labelLarge + ) + } + } + ) +} + @Preview(uiMode = Configuration.UI_MODE_NIGHT_NO) @Preview(uiMode = Configuration.UI_MODE_NIGHT_YES) @Composable @@ -1706,3 +1753,7 @@ private val mockChapterBlock = Block( due = Date(), offlineDownload = null ) + + + + diff --git a/course/src/main/res/values/strings.xml b/course/src/main/res/values/strings.xml index 33852c242..0c130ac20 100644 --- a/course/src/main/res/values/strings.xml +++ b/course/src/main/res/values/strings.xml @@ -45,6 +45,14 @@ Videos Assignments + + Course Completion + Progress + You have completed %1$s%% of the course content + Completed + Next Sequence + View All Content + Video player Remove course section From 7d6610f1a1b475e7252536c1baf1d8d6411507fe Mon Sep 17 00:00:00 2001 From: PavloNetrebchuk Date: Wed, 3 Sep 2025 14:14:35 +0300 Subject: [PATCH 03/17] feat: move HomeNavigationRow to bottom bar --- .../container/CourseContainerFragment.kt | 125 +++++++++++++++--- .../presentation/home/CourseHomeScreen.kt | 99 +++----------- 2 files changed, 121 insertions(+), 103 deletions(-) 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 70f0d9d20..0cf27b1b0 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 @@ -13,6 +13,7 @@ import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.navigationBarsPadding @@ -26,6 +27,8 @@ import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll import androidx.compose.material.Divider import androidx.compose.material.ExperimentalMaterialApi +import androidx.compose.material.Icon +import androidx.compose.material.IconButton import androidx.compose.material.MaterialTheme import androidx.compose.material.Scaffold import androidx.compose.material.SnackbarData @@ -34,6 +37,9 @@ import androidx.compose.material.SnackbarHost import androidx.compose.material.SnackbarHostState import androidx.compose.material.Text import androidx.compose.material.TextButton +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBackIos +import androidx.compose.material.icons.automirrored.filled.ArrowForwardIos import androidx.compose.material.pullrefresh.PullRefreshIndicator import androidx.compose.material.pullrefresh.pullRefresh import androidx.compose.material.pullrefresh.rememberPullRefreshState @@ -77,6 +83,7 @@ import org.openedx.core.ui.HandleUIMessage import org.openedx.core.ui.IconText import org.openedx.core.ui.OfflineModeDialog import org.openedx.core.ui.OpenEdXButton +import org.openedx.core.ui.PageIndicator import org.openedx.core.ui.RoundTabsBar import org.openedx.core.ui.statusBarsInset import org.openedx.core.ui.theme.OpenEdXTheme @@ -90,6 +97,7 @@ import org.openedx.course.presentation.contenttab.ContentTabScreen import org.openedx.course.presentation.dates.CourseDatesScreen import org.openedx.course.presentation.handouts.HandoutsScreen import org.openedx.course.presentation.handouts.HandoutsType +import org.openedx.course.presentation.home.CourseHomePagerTab import org.openedx.course.presentation.home.CourseHomeScreen import org.openedx.course.presentation.offline.CourseOfflineScreen import org.openedx.course.presentation.progress.CourseProgressScreen @@ -273,6 +281,10 @@ fun CourseDashboard( initialPage = 0, pageCount = { CourseContentTab.entries.size } ) + val homePagerState = rememberPagerState( + initialPage = 0, + pageCount = { CourseHomePagerTab.entries.size } + ) val accessStatus = viewModel.courseAccessStatus.observeAsState() val tabState = rememberLazyListState() val snackState = remember { SnackbarHostState() } @@ -293,28 +305,12 @@ fun CourseDashboard( scaffoldState = scaffoldState, backgroundColor = MaterialTheme.appColors.background, bottomBar = { + val currentPage = CourseContainerTab.entries[pagerState.currentPage] Box { - if (CourseContainerTab.entries[pagerState.currentPage] == CourseContainerTab.CONTENT && - selectedContentTab == CourseContentTab.ASSIGNMENTS - ) { - Column( - modifier = Modifier.background(MaterialTheme.appColors.background), - horizontalAlignment = Alignment.CenterHorizontally - ) { - Divider(modifier = Modifier.fillMaxWidth()) - TextButton( - onClick = { - scrollToProgress(scope, pagerState) - } - ) { - IconText( - text = stringResource(R.string.course_review_grading_policy), - painter = painterResource(id = coreR.drawable.core_ic_mountains), - color = MaterialTheme.appColors.primary, - textStyle = MaterialTheme.appTypography.labelLarge - ) - } - } + if (currentPage == CourseContainerTab.CONTENT && selectedContentTab == CourseContentTab.ASSIGNMENTS) { + AssignmentsBottomBar(scope = scope, pagerState = pagerState) + } else if (currentPage == CourseContainerTab.HOME) { + HomeNavigationRow(homePagerState = homePagerState) } var isInternetConnectionShown by rememberSaveable { mutableStateOf(false) @@ -412,6 +408,7 @@ fun CourseDashboard( viewModel = viewModel, pagerState = pagerState, contentTabPagerState = contentTabPagerState, + homePagerState = homePagerState, isResumed = isResumed, fragmentManager = fragmentManager, onContentTabSelected = { tab -> @@ -457,6 +454,7 @@ private fun DashboardPager( viewModel: CourseContainerViewModel, pagerState: PagerState, contentTabPagerState: PagerState, + homePagerState: PagerState, isResumed: Boolean, fragmentManager: FragmentManager, onContentTabSelected: (CourseContentTab) -> Unit, @@ -476,6 +474,7 @@ private fun DashboardPager( parameters = { parametersOf(viewModel.courseId, viewModel.courseName) } ), fragmentManager = fragmentManager, + homePagerState = homePagerState, onResetDatesClick = { viewModel.onRefresh(CourseContainerTab.DATES) }, @@ -713,3 +712,87 @@ private fun scrollToProgress(scope: CoroutineScope, pagerState: PagerState) { pagerState.animateScrollToPage(CourseContainerTab.entries.indexOf(CourseContainerTab.PROGRESS)) } } + +@Composable +private fun HomeNavigationRow(homePagerState: PagerState) { + val homeCoroutineScope = rememberCoroutineScope() + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + val isPreviousPageEnabled = homePagerState.currentPage > 0 + IconButton( + enabled = homePagerState.currentPage > 0, + onClick = { + homeCoroutineScope.launch { + homePagerState.animateScrollToPage(homePagerState.currentPage - 1) + } + } + ) { + Icon( + modifier = Modifier.size(12.dp), + imageVector = Icons.AutoMirrored.Filled.ArrowBackIos, + contentDescription = stringResource(coreR.string.core_previous), + tint = if (isPreviousPageEnabled) { + MaterialTheme.appColors.textDark + } else { + MaterialTheme.appColors.textFieldHint + } + ) + } + PageIndicator( + modifier = Modifier.padding(vertical = 16.dp), + numberOfPages = CourseHomePagerTab.entries.size, + selectedPage = homePagerState.currentPage, + defaultRadius = 8.dp, + space = 8.dp, + selectedLength = 24.dp, + ) + val isNextPageEnabled = homePagerState.currentPage < CourseHomePagerTab.entries.size - 1 + IconButton( + enabled = isNextPageEnabled, + onClick = { + homeCoroutineScope.launch { + homePagerState.animateScrollToPage(homePagerState.currentPage + 1) + } + } + ) { + Icon( + modifier = Modifier.size(12.dp), + imageVector = Icons.AutoMirrored.Filled.ArrowForwardIos, + contentDescription = stringResource(coreR.string.core_next), + tint = if (isNextPageEnabled) { + MaterialTheme.appColors.textDark + } else { + MaterialTheme.appColors.textFieldHint + } + ) + } + } +} + +@Composable +private fun AssignmentsBottomBar( + scope: CoroutineScope, + pagerState: PagerState +) { + Column( + modifier = Modifier.background(MaterialTheme.appColors.background), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Divider(modifier = Modifier.fillMaxWidth()) + TextButton( + onClick = { + scrollToProgress(scope, pagerState) + } + ) { + IconText( + text = stringResource(R.string.course_review_grading_policy), + painter = painterResource(id = coreR.drawable.core_ic_mountains), + color = MaterialTheme.appColors.primary, + textStyle = MaterialTheme.appTypography.labelLarge + ) + } + } +} diff --git a/course/src/main/java/org/openedx/course/presentation/home/CourseHomeScreen.kt b/course/src/main/java/org/openedx/course/presentation/home/CourseHomeScreen.kt index 342f04e8d..b1e1f24ff 100644 --- a/course/src/main/java/org/openedx/course/presentation/home/CourseHomeScreen.kt +++ b/course/src/main/java/org/openedx/course/presentation/home/CourseHomeScreen.kt @@ -3,32 +3,25 @@ package org.openedx.course.presentation.home import android.content.res.Configuration.UI_MODE_NIGHT_NO import android.content.res.Configuration.UI_MODE_NIGHT_YES import androidx.compose.foundation.BorderStroke -import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues -import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.pager.HorizontalPager +import androidx.compose.foundation.pager.PagerState import androidx.compose.foundation.pager.rememberPagerState import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll import androidx.compose.material.Card -import androidx.compose.material.Icon -import androidx.compose.material.IconButton 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.filled.ArrowBackIos -import androidx.compose.material.icons.automirrored.filled.ArrowForwardIos import androidx.compose.material.rememberScaffoldState import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect @@ -36,7 +29,6 @@ import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember -import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.platform.AndroidUriHandler @@ -48,7 +40,6 @@ import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import androidx.fragment.app.FragmentManager -import kotlinx.coroutines.launch import org.openedx.core.Mock import org.openedx.core.NoContentScreenType import org.openedx.core.domain.model.Block @@ -57,7 +48,6 @@ import org.openedx.core.presentation.course.CourseViewMode import org.openedx.core.ui.CircularProgress import org.openedx.core.ui.HandleUIMessage import org.openedx.core.ui.NoContentScreen -import org.openedx.core.ui.PageIndicator import org.openedx.core.ui.displayCutoutForLandscape import org.openedx.core.ui.theme.OpenEdXTheme import org.openedx.core.ui.theme.appColors @@ -73,13 +63,13 @@ import org.openedx.foundation.presentation.UIMessage import org.openedx.foundation.presentation.WindowSize import org.openedx.foundation.presentation.WindowType import org.openedx.foundation.presentation.windowSizeValue -import org.openedx.core.R as coreR @Composable fun CourseHomeScreen( windowSize: WindowSize, viewModel: CourseHomeViewModel, fragmentManager: FragmentManager, + homePagerState: PagerState, onResetDatesClick: () -> Unit, onNavigateToContent: (CourseContentTab) -> Unit = {}, ) { @@ -98,6 +88,7 @@ fun CourseHomeScreen( windowSize = windowSize, uiState = uiState, uiMessage = uiMessage, + homePagerState = homePagerState, onSubSectionClick = { subSectionBlock -> if (viewModel.isCourseDropdownNavigationEnabled) { viewModel.courseSubSectionUnit[subSectionBlock.id]?.let { unit -> @@ -158,6 +149,7 @@ private fun CourseHomeUI( windowSize: WindowSize, uiState: CourseHomeUIState, uiMessage: UIMessage?, + homePagerState: PagerState, onSubSectionClick: (Block) -> Unit, onResumeClick: (String) -> Unit, onDownloadClick: (blockIds: List) -> Unit, @@ -182,15 +174,6 @@ private fun CourseHomeUI( ) } - val bottomPadding by remember(key1 = windowSize) { - mutableStateOf( - windowSize.windowSizeValue( - expanded = PaddingValues(bottom = 24.dp), - compact = PaddingValues(bottom = 24.dp) - ) - ) - } - HandleUIMessage(uiMessage = uiMessage, scaffoldState = scaffoldState) Box( @@ -213,7 +196,6 @@ private fun CourseHomeUI( Column( modifier = Modifier .fillMaxSize() - .padding(bottomPadding) .verticalScroll(rememberScrollState()), ) { if (uiState.datesBannerInfo.isBannerAvailableForDashboard()) { @@ -270,7 +252,8 @@ private fun CourseHomeUI( Spacer(modifier = Modifier.height(12.dp)) CourseHomePager( modifier = Modifier.fillMaxSize(), - pages = CourseHomePagerTab.entries + pages = CourseHomePagerTab.entries, + pagerState = homePagerState ) { tab -> Card( modifier = Modifier.fillMaxWidth(), @@ -322,13 +305,9 @@ private fun CourseHomeUI( fun CourseHomePager( modifier: Modifier = Modifier, pages: List, + pagerState: PagerState, pageContent: @Composable (T) -> Unit ) { - val pagerState = rememberPagerState( - initialPage = 0, - pageCount = { pages.size } - ) - val coroutineScope = rememberCoroutineScope() Column( modifier = modifier, @@ -343,60 +322,6 @@ fun CourseHomePager( ) { page -> pageContent(pages[page]) } - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically - ) { - val isPreviousPageEnabled = pagerState.currentPage > 0 - IconButton( - enabled = pagerState.currentPage > 0, - onClick = { - coroutineScope.launch { - pagerState.animateScrollToPage(pagerState.currentPage - 1) - } - } - ) { - Icon( - modifier = Modifier.size(12.dp), - imageVector = Icons.AutoMirrored.Filled.ArrowBackIos, - contentDescription = stringResource(coreR.string.core_previous), - tint = if (isPreviousPageEnabled) { - MaterialTheme.appColors.textDark - } else { - MaterialTheme.appColors.textFieldHint - } - ) - } - PageIndicator( - modifier = Modifier.padding(vertical = 16.dp), - numberOfPages = pages.size, - selectedPage = pagerState.currentPage, - defaultRadius = 8.dp, - space = 8.dp, - selectedLength = 24.dp, - ) - val isNextPageEnabled = pagerState.currentPage < pages.size - 1 - IconButton( - enabled = isNextPageEnabled, - onClick = { - coroutineScope.launch { - pagerState.animateScrollToPage(pagerState.currentPage + 1) - } - } - ) { - Icon( - modifier = Modifier.size(12.dp), - imageVector = Icons.AutoMirrored.Filled.ArrowForwardIos, - contentDescription = stringResource(coreR.string.core_next), - tint = if (isNextPageEnabled) { - MaterialTheme.appColors.textDark - } else { - MaterialTheme.appColors.textFieldHint - } - ) - } - } } } @@ -405,6 +330,10 @@ fun CourseHomePager( @Composable private fun CourseHomeScreenPreview() { OpenEdXTheme { + val previewPagerState = rememberPagerState( + initialPage = 0, + pageCount = { CourseHomePagerTab.entries.size } + ) CourseHomeUI( windowSize = WindowSize(WindowType.Compact, WindowType.Compact), uiState = CourseHomeUIState.CourseData( @@ -426,6 +355,7 @@ private fun CourseHomeScreenPreview() { useRelativeDates = true ), uiMessage = null, + homePagerState = previewPagerState, onSubSectionClick = {}, onResumeClick = {}, onDownloadClick = {}, @@ -441,6 +371,10 @@ private fun CourseHomeScreenPreview() { @Composable private fun CourseHomeScreenTabletPreview() { OpenEdXTheme { + val previewPagerState = rememberPagerState( + initialPage = 0, + pageCount = { CourseHomePagerTab.entries.size } + ) CourseHomeUI( windowSize = WindowSize(WindowType.Medium, WindowType.Medium), uiState = CourseHomeUIState.CourseData( @@ -462,6 +396,7 @@ private fun CourseHomeScreenTabletPreview() { useRelativeDates = true ), uiMessage = null, + homePagerState = previewPagerState, onSubSectionClick = {}, onResumeClick = {}, onDownloadClick = {}, From 8625632e6e978c7e8d948ee70c94f1d4ecd02963 Mon Sep 17 00:00:00 2001 From: PavloNetrebchuk Date: Wed, 3 Sep 2025 16:26:05 +0300 Subject: [PATCH 04/17] feat: course home pages videos card --- .../java/org/openedx/app/di/ScreenModule.kt | 1 + .../CourseCompletionHomePagerCardContent.kt | 5 +- .../presentation/home/CourseHomeScreen.kt | 233 ++++++++++-------- .../presentation/home/CourseHomeUIState.kt | 4 + .../presentation/home/CourseHomeViewModel.kt | 69 +++++- .../home/VideosHomePagerCardContent.kt | 150 +++++++++++ .../course/presentation/ui/CourseUI.kt | 8 +- course/src/main/res/values/strings.xml | 6 + 8 files changed, 364 insertions(+), 112 deletions(-) create mode 100644 course/src/main/java/org/openedx/course/presentation/home/VideosHomePagerCardContent.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 ca3084c2f..07caf8037 100644 --- a/app/src/main/java/org/openedx/app/di/ScreenModule.kt +++ b/app/src/main/java/org/openedx/app/di/ScreenModule.kt @@ -328,6 +328,7 @@ val screenModule = module { get(), get(), get(), + get() ) } viewModel { (courseId: String) -> diff --git a/course/src/main/java/org/openedx/course/presentation/home/CourseCompletionHomePagerCardContent.kt b/course/src/main/java/org/openedx/course/presentation/home/CourseCompletionHomePagerCardContent.kt index b5f4b6e23..095258242 100644 --- a/course/src/main/java/org/openedx/course/presentation/home/CourseCompletionHomePagerCardContent.kt +++ b/course/src/main/java/org/openedx/course/presentation/home/CourseCompletionHomePagerCardContent.kt @@ -163,7 +163,10 @@ private fun CourseCompletionHomePagerCardContentPreview() { contentTypeGatingEnabled = false, hasEnded = false ), - useRelativeDates = true + useRelativeDates = true, + courseVideos = mapOf(), + videoPreview = null, + videoProgress = 0f ), onViewAllContentClick = {}, onDownloadClick = {}, diff --git a/course/src/main/java/org/openedx/course/presentation/home/CourseHomeScreen.kt b/course/src/main/java/org/openedx/course/presentation/home/CourseHomeScreen.kt index b1e1f24ff..3a37c2cca 100644 --- a/course/src/main/java/org/openedx/course/presentation/home/CourseHomeScreen.kt +++ b/course/src/main/java/org/openedx/course/presentation/home/CourseHomeScreen.kt @@ -140,6 +140,15 @@ fun CourseHomeScreen( it.takeIfNotEmpty() ?.let { url -> AndroidUriHandler(context).openUri(url) } }, + onVideoClick = { videoBlock -> + viewModel.courseRouter.navigateToCourseContainer( + fragmentManager, + courseId = viewModel.courseId, + unitId = viewModel.getBlockParent(videoBlock.id)?.id ?: return@CourseHomeUI, + mode = CourseViewMode.VIDEOS + ) + viewModel.logVideoClick(videoBlock.id) + }, onNavigateToContent = onNavigateToContent ) } @@ -155,6 +164,7 @@ private fun CourseHomeUI( onDownloadClick: (blockIds: List) -> Unit, onResetDatesClick: () -> Unit, onCertificateClick: (String) -> Unit, + onVideoClick: (Block) -> Unit, onNavigateToContent: (CourseContentTab) -> Unit, ) { val scaffoldState = rememberScaffoldState() @@ -187,113 +197,121 @@ private fun CourseHomeUI( modifier = screenWidth, color = MaterialTheme.appColors.background ) { - Box { - when (uiState) { - is CourseHomeUIState.CourseData -> { - if (uiState.courseStructure.blockData.isEmpty()) { - NoContentScreen(noContentScreenType = NoContentScreenType.COURSE_OUTLINE) - } else { - Column( - modifier = Modifier - .fillMaxSize() - .verticalScroll(rememberScrollState()), - ) { - if (uiState.datesBannerInfo.isBannerAvailableForDashboard()) { - Box( - modifier = Modifier - .padding(all = 8.dp) - ) { - if (windowSize.isTablet) { - CourseDatesBannerTablet( - banner = uiState.datesBannerInfo, - resetDates = onResetDatesClick, - ) - } else { - CourseDatesBanner( - banner = uiState.datesBannerInfo, - resetDates = onResetDatesClick, - ) - } + when (uiState) { + is CourseHomeUIState.CourseData -> { + if (uiState.courseStructure.blockData.isEmpty()) { + NoContentScreen(noContentScreenType = NoContentScreenType.COURSE_OUTLINE) + } else { + Column( + modifier = Modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()), + ) { + if (uiState.datesBannerInfo.isBannerAvailableForDashboard()) { + Box( + modifier = Modifier + .padding(all = 8.dp) + ) { + if (windowSize.isTablet) { + CourseDatesBannerTablet( + banner = uiState.datesBannerInfo, + resetDates = onResetDatesClick, + ) + } else { + CourseDatesBanner( + banner = uiState.datesBannerInfo, + resetDates = onResetDatesClick, + ) } } + } - val certificate = uiState.courseStructure.certificate - if (certificate?.isCertificateEarned() == true) { - CourseMessage( - modifier = Modifier - .fillMaxWidth() - .padding( - vertical = 12.dp, - horizontal = 24.dp - ), - icon = painterResource(R.drawable.course_ic_certificate), - message = stringResource( - R.string.course_you_earned_certificate, - uiState.courseStructure.name + val certificate = uiState.courseStructure.certificate + if (certificate?.isCertificateEarned() == true) { + CourseMessage( + modifier = Modifier + .fillMaxWidth() + .padding( + vertical = 12.dp, + horizontal = 24.dp ), - action = stringResource(R.string.course_view_certificate), - onActionClick = { - onCertificateClick( - certificate.certificateURL ?: "" + icon = painterResource(R.drawable.course_ic_certificate), + message = stringResource( + R.string.course_you_earned_certificate, + uiState.courseStructure.name + ), + action = stringResource(R.string.course_view_certificate), + onActionClick = { + onCertificateClick( + certificate.certificateURL ?: "" + ) + } + ) + } + + if (uiState.resumeComponent != null) { + ResumeCourseButton( + modifier = Modifier.padding(16.dp), + block = uiState.resumeComponent, + displayName = uiState.resumeUnitTitle, + onResumeClick = onResumeClick + ) + } + + Spacer(modifier = Modifier.height(12.dp)) + CourseHomePager( + modifier = Modifier.fillMaxSize(), + pages = CourseHomePagerTab.entries, + pagerState = homePagerState + ) { tab -> + Card( + modifier = Modifier.fillMaxWidth(), + backgroundColor = MaterialTheme.appColors.cardViewBackground, + border = BorderStroke( + 1.dp, + MaterialTheme.appColors.cardViewBorder + ), + shape = MaterialTheme.appShapes.cardShape, + elevation = 0.dp, + ) { + when (tab) { + CourseHomePagerTab.COURSE_COMPLETION -> { + CourseCompletionHomePagerCardContent( + uiState = uiState, + onViewAllContentClick = { + onNavigateToContent(CourseContentTab.ALL) + }, + onDownloadClick = onDownloadClick, + onSubSectionClick = onSubSectionClick ) } - ) - } - if (uiState.resumeComponent != null) { - ResumeCourseButton( - modifier = Modifier.padding(16.dp), - block = uiState.resumeComponent, - displayName = uiState.resumeUnitTitle, - onResumeClick = onResumeClick - ) - } - - Spacer(modifier = Modifier.height(12.dp)) - CourseHomePager( - modifier = Modifier.fillMaxSize(), - pages = CourseHomePagerTab.entries, - pagerState = homePagerState - ) { tab -> - Card( - modifier = Modifier.fillMaxWidth(), - backgroundColor = MaterialTheme.appColors.cardViewBackground, - border = BorderStroke( - 1.dp, - MaterialTheme.appColors.cardViewBorder - ), - shape = MaterialTheme.appShapes.cardShape, - elevation = 0.dp, - ) { - when (tab) { - CourseHomePagerTab.COURSE_COMPLETION -> { - CourseCompletionHomePagerCardContent( - uiState = uiState, - onViewAllContentClick = { - onNavigateToContent(CourseContentTab.ALL) - }, - onDownloadClick = onDownloadClick, - onSubSectionClick = onSubSectionClick - ) - } + CourseHomePagerTab.VIDEOS -> { + VideosHomePagerCardContent( + uiState = uiState, + onVideoClick = onVideoClick, + onViewAllVideosClick = { + onNavigateToContent(CourseContentTab.VIDEOS) + } + ) + } - else -> { - Text(tab.name) - } + else -> { + Text(tab.name) } } } } } } + } - CourseHomeUIState.Error -> { - NoContentScreen(noContentScreenType = NoContentScreenType.COURSE_OUTLINE) - } + CourseHomeUIState.Error -> { + NoContentScreen(noContentScreenType = NoContentScreenType.COURSE_OUTLINE) + } - CourseHomeUIState.Loading -> { - CircularProgress() - } + CourseHomeUIState.Loading -> { + CircularProgress() } } } @@ -308,20 +326,15 @@ fun CourseHomePager( pagerState: PagerState, pageContent: @Composable (T) -> Unit ) { - - Column( + HorizontalPager( modifier = modifier, - horizontalAlignment = Alignment.CenterHorizontally, - ) { - HorizontalPager( - modifier = Modifier.fillMaxWidth(), - state = pagerState, - contentPadding = PaddingValues(horizontal = 16.dp), - pageSpacing = 8.dp, - beyondViewportPageCount = pages.size - ) { page -> - pageContent(pages[page]) - } + state = pagerState, + contentPadding = PaddingValues(horizontal = 16.dp), + pageSpacing = 8.dp, + beyondViewportPageCount = pages.size, + verticalAlignment = Alignment.Top + ) { page -> + pageContent(pages[page]) } } @@ -352,7 +365,10 @@ private fun CourseHomeScreenPreview() { contentTypeGatingEnabled = false, hasEnded = false ), - useRelativeDates = true + useRelativeDates = true, + courseVideos = mapOf(), + videoPreview = null, + videoProgress = 0f ), uiMessage = null, homePagerState = previewPagerState, @@ -361,6 +377,7 @@ private fun CourseHomeScreenPreview() { onDownloadClick = {}, onResetDatesClick = {}, onCertificateClick = {}, + onVideoClick = {}, onNavigateToContent = { _ -> }, ) } @@ -393,7 +410,10 @@ private fun CourseHomeScreenTabletPreview() { contentTypeGatingEnabled = false, hasEnded = false ), - useRelativeDates = true + useRelativeDates = true, + courseVideos = mapOf(), + videoPreview = null, + videoProgress = 0f ), uiMessage = null, homePagerState = previewPagerState, @@ -402,6 +422,7 @@ private fun CourseHomeScreenTabletPreview() { onDownloadClick = {}, onResetDatesClick = {}, onCertificateClick = {}, + onVideoClick = {}, onNavigateToContent = { _ -> }, ) } diff --git a/course/src/main/java/org/openedx/course/presentation/home/CourseHomeUIState.kt b/course/src/main/java/org/openedx/course/presentation/home/CourseHomeUIState.kt index 743e5374d..78bd5a749 100644 --- a/course/src/main/java/org/openedx/course/presentation/home/CourseHomeUIState.kt +++ b/course/src/main/java/org/openedx/course/presentation/home/CourseHomeUIState.kt @@ -5,6 +5,7 @@ import org.openedx.core.domain.model.CourseDatesBannerInfo import org.openedx.core.domain.model.CourseProgress import org.openedx.core.domain.model.CourseStructure import org.openedx.core.module.db.DownloadedState +import org.openedx.core.utils.VideoPreview sealed class CourseHomeUIState { data class CourseData( @@ -18,6 +19,9 @@ sealed class CourseHomeUIState { val subSectionsDownloadsCount: Map, val datesBannerInfo: CourseDatesBannerInfo, val useRelativeDates: Boolean, + val courseVideos: Map>, + val videoPreview: VideoPreview?, + val videoProgress: Float, ) : CourseHomeUIState() data object Error : CourseHomeUIState() diff --git a/course/src/main/java/org/openedx/course/presentation/home/CourseHomeViewModel.kt b/course/src/main/java/org/openedx/course/presentation/home/CourseHomeViewModel.kt index 6cd18a858..6b9578120 100644 --- a/course/src/main/java/org/openedx/course/presentation/home/CourseHomeViewModel.kt +++ b/course/src/main/java/org/openedx/course/presentation/home/CourseHomeViewModel.kt @@ -1,5 +1,6 @@ package org.openedx.course.presentation.home +import android.content.Context import androidx.fragment.app.FragmentManager import androidx.lifecycle.viewModelScope import kotlinx.coroutines.flow.MutableSharedFlow @@ -51,6 +52,7 @@ import org.openedx.course.R as courseR class CourseHomeViewModel( val courseId: String, private val courseTitle: String, + private val context: Context, private val config: Config, private val interactor: CourseInteractor, private val resourceManager: ResourceManager, @@ -94,6 +96,7 @@ class CourseHomeViewModel( private val courseSubSections = mutableMapOf>() private val subSectionsDownloadsCount = mutableMapOf() val courseSubSectionUnit = mutableMapOf() + private val courseVideos = mutableMapOf>() init { viewModelScope.launch { @@ -130,7 +133,10 @@ class CourseHomeViewModel( datesBannerInfo = state.datesBannerInfo, useRelativeDates = preferencesManager.isRelativeDatesEnabled, next = state.next, - courseProgress = state.courseProgress + courseProgress = state.courseProgress, + courseVideos = state.courseVideos, + videoPreview = state.videoPreview, + videoProgress = state.videoProgress ) } } @@ -201,10 +207,32 @@ class CourseHomeViewModel( setBlocks(blocks) courseSubSections.clear() courseSubSectionUnit.clear() + courseVideos.clear() val sortedStructure = courseStructure.copy(blockData = sortBlocks(blocks)) initDownloadModelsStatus() val nextSection = findFirstChapterWithIncompleteDescendants(blocks) + // Get video data + val allVideos = courseVideos.values.flatten() + val firstIncompleteVideo = allVideos.find { !it.isCompleted() } + val videoPreview = firstIncompleteVideo?.getVideoPreview( + context, + networkConnection.isOnline(), + null + ) + val videoProgress = if (firstIncompleteVideo != null) { + try { + val videoProgressEntity = interactor.getVideoProgress(firstIncompleteVideo.id) + val progress = + videoProgressEntity.videoTime.toFloat() / videoProgressEntity.duration.toFloat() + progress.coerceIn(0f, 1f) + } catch (e: Exception) { + 0f + } + } else { + 0f + } + _uiState.value = CourseHomeUIState.CourseData( courseStructure = sortedStructure, next = nextSection, @@ -215,7 +243,10 @@ class CourseHomeViewModel( subSectionsDownloadsCount = subSectionsDownloadsCount, datesBannerInfo = datesBannerInfo, useRelativeDates = preferencesManager.isRelativeDatesEnabled, - courseProgress = courseProgress + courseProgress = courseProgress, + courseVideos = courseVideos, + videoPreview = videoPreview, + videoProgress = videoProgress ) } @@ -250,6 +281,15 @@ class CourseHomeViewModel( subSectionsDownloadsCount[sequentialBlock.id] = sequentialBlock.getDownloadsCount(blocks) addDownloadableChildrenForSequentialBlock(sequentialBlock) + + // Add video processing logic + val verticalBlocks = blocks.filter { block -> + block.id in sequentialBlock.descendants + } + val videoBlocks = blocks.filter { block -> + verticalBlocks.any { vertical -> block.id in vertical.descendants } && block.type == BlockType.VIDEO + } + addToVideos(block, videoBlocks) } } @@ -257,6 +297,14 @@ class CourseHomeViewModel( courseSubSections.getOrPut(block.id) { mutableListOf() }.add(sequentialBlock) } + private fun addToVideos(chapterBlock: Block, videoBlocks: List) { + courseVideos.getOrPut(chapterBlock.id) { mutableListOf() }.addAll(videoBlocks) + } + + fun getBlockParent(blockId: String): Block? { + return allBlocks.values.find { blockId in it.descendants } + } + private fun getResumeBlock( blocks: List, continueBlockId: String, @@ -485,4 +533,21 @@ class CourseHomeViewModel( } } } + + fun logVideoClick(blockId: String) { + val currentState = uiState.value + if (currentState is CourseHomeUIState.CourseData) { + analytics.logEvent( + CourseAnalyticsEvent.COURSE_CONTENT_VIDEO_CLICK.eventName, + buildMap { + put( + CourseAnalyticsKey.NAME.key, + CourseAnalyticsEvent.COURSE_CONTENT_VIDEO_CLICK.biValue + ) + put(CourseAnalyticsKey.COURSE_ID.key, courseId) + put(CourseAnalyticsKey.BLOCK_ID.key, blockId) + } + ) + } + } } diff --git a/course/src/main/java/org/openedx/course/presentation/home/VideosHomePagerCardContent.kt b/course/src/main/java/org/openedx/course/presentation/home/VideosHomePagerCardContent.kt new file mode 100644 index 000000000..37e369c5e --- /dev/null +++ b/course/src/main/java/org/openedx/course/presentation/home/VideosHomePagerCardContent.kt @@ -0,0 +1,150 @@ +package org.openedx.course.presentation.home + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material.Icon +import androidx.compose.material.LinearProgressIndicator +import androidx.compose.material.MaterialTheme +import androidx.compose.material.Text +import androidx.compose.material.TextButton +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.List +import androidx.compose.material.icons.filled.Videocam +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import org.openedx.core.domain.model.Block +import org.openedx.core.ui.theme.appColors +import org.openedx.core.ui.theme.appTypography +import org.openedx.course.R +import org.openedx.course.presentation.ui.CourseVideoItem + +@Composable +fun VideosHomePagerCardContent( + uiState: CourseHomeUIState.CourseData, + onVideoClick: (Block) -> Unit, + onViewAllVideosClick: () -> Unit +) { + val allVideos = uiState.courseVideos.values.flatten() + val completedVideos = allVideos.count { it.isCompleted() } + val totalVideos = allVideos.size + val firstIncompleteVideo = allVideos.find { !it.isCompleted() } + + Column( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp) + ) { + // Header with progress + Text( + text = stringResource(R.string.course_container_content_tab_video), + style = MaterialTheme.appTypography.titleLarge, + color = MaterialTheme.appColors.textPrimary, + fontWeight = FontWeight.SemiBold + ) + Spacer(modifier = Modifier.height(12.dp)) + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + imageVector = Icons.Filled.Videocam, + contentDescription = null, + tint = MaterialTheme.appColors.textPrimary, + modifier = Modifier.size(32.dp) + ) + Spacer(modifier = Modifier.width(8.dp)) + Text( + text = "$completedVideos/$totalVideos", + style = MaterialTheme.appTypography.displaySmall, + color = MaterialTheme.appColors.textPrimary, + fontWeight = FontWeight.Bold + ) + Spacer(modifier = Modifier.width(8.dp)) + Text( + text = stringResource(R.string.course_videos_completed), + style = MaterialTheme.appTypography.labelLarge, + color = MaterialTheme.appColors.textPrimaryVariant, + fontWeight = FontWeight.Medium + ) + } + + Spacer(modifier = Modifier.height(8.dp)) + + // Progress bar + LinearProgressIndicator( + modifier = Modifier + .fillMaxWidth() + .height(4.dp) + .clip(CircleShape), + progress = if (totalVideos > 0) completedVideos.toFloat() / totalVideos else 0f, + color = MaterialTheme.appColors.progressBarColor, + backgroundColor = MaterialTheme.appColors.progressBarBackgroundColor + ) + + Spacer(modifier = Modifier.height(20.dp)) + + // Continue Watching section + if (firstIncompleteVideo != null) { + Text( + text = stringResource(R.string.course_continue_watching), + style = MaterialTheme.appTypography.titleMedium, + color = MaterialTheme.appColors.textPrimary, + fontWeight = FontWeight.SemiBold + ) + + Spacer(modifier = Modifier.height(8.dp)) + + // Video card using CourseVideoItem + Box( + modifier = Modifier.fillMaxWidth(), + contentAlignment = Alignment.Center + ) { + CourseVideoItem( + modifier = Modifier + .fillMaxWidth() + .height(180.dp), + videoBlock = firstIncompleteVideo, + preview = uiState.videoPreview, + progress = uiState.videoProgress, + onClick = { + onVideoClick(firstIncompleteVideo) + } + ) + } + } + + Spacer(modifier = Modifier.height(8.dp)) + + // View All Videos button + TextButton( + onClick = onViewAllVideosClick, + modifier = Modifier.fillMaxWidth() + ) { + Icon( + imageVector = Icons.AutoMirrored.Filled.List, + contentDescription = null, + tint = MaterialTheme.appColors.primary, + modifier = Modifier.size(20.dp) + ) + Spacer(modifier = Modifier.width(8.dp)) + Text( + text = stringResource(R.string.course_view_all_videos), + style = MaterialTheme.appTypography.labelLarge, + color = MaterialTheme.appColors.primary + ) + } + } +} diff --git a/course/src/main/java/org/openedx/course/presentation/ui/CourseUI.kt b/course/src/main/java/org/openedx/course/presentation/ui/CourseUI.kt index c26ecb72a..74a5ad0ea 100644 --- a/course/src/main/java/org/openedx/course/presentation/ui/CourseUI.kt +++ b/course/src/main/java/org/openedx/course/presentation/ui/CourseUI.kt @@ -664,6 +664,9 @@ fun CourseVideoSection( ) { items(videoBlocks) { block -> CourseVideoItem( + modifier = Modifier + .width(192.dp) + .height(108.dp), videoBlock = block, preview = preview[block.id], progress = progress[block.id] ?: 0f, @@ -679,15 +682,14 @@ fun CourseVideoSection( @Composable fun CourseVideoItem( + modifier: Modifier = Modifier, videoBlock: Block, preview: VideoPreview?, progress: Float, onClick: () -> Unit ) { Box( - modifier = Modifier - .width(192.dp) - .height(108.dp) + modifier = modifier .clip(MaterialTheme.appShapes.videoPreviewShape) .let { if (videoBlock.isCompleted()) { diff --git a/course/src/main/res/values/strings.xml b/course/src/main/res/values/strings.xml index 0c130ac20..0080e8a1b 100644 --- a/course/src/main/res/values/strings.xml +++ b/course/src/main/res/values/strings.xml @@ -89,4 +89,10 @@ %1$s %% of Grade Review Course Grading Policy Return to Course Home + + + Continue Watching + View All Videos + %1$s left + Videos\ncompleted From d1ed4bf1b68d9b80ea5077372ca6537cf6ad6f3f Mon Sep 17 00:00:00 2001 From: PavloNetrebchuk Date: Wed, 3 Sep 2025 18:20:26 +0300 Subject: [PATCH 05/17] feat: course home pages assignment card --- .../java/org/openedx/core/utils/TimeUtils.kt | 5 + .../home/AssignmentsHomePagerCardContent.kt | 265 ++++++++++++++++++ .../CourseCompletionHomePagerCardContent.kt | 1 + .../presentation/home/CourseHomeScreen.kt | 24 ++ .../presentation/home/CourseHomeUIState.kt | 1 + .../presentation/home/CourseHomeViewModel.kt | 34 ++- course/src/main/res/values/strings.xml | 9 + 7 files changed, 338 insertions(+), 1 deletion(-) create mode 100644 course/src/main/java/org/openedx/course/presentation/home/AssignmentsHomePagerCardContent.kt diff --git a/core/src/main/java/org/openedx/core/utils/TimeUtils.kt b/core/src/main/java/org/openedx/core/utils/TimeUtils.kt index 572d4bc5c..ad6ef7fff 100644 --- a/core/src/main/java/org/openedx/core/utils/TimeUtils.kt +++ b/core/src/main/java/org/openedx/core/utils/TimeUtils.kt @@ -97,6 +97,11 @@ object TimeUtils { } } + fun formatToDayMonth(date: Date): String { + val sdf = SimpleDateFormat("MMM dd", Locale.getDefault()) + return sdf.format(date) + } + fun getCurrentTime(): Long { return Calendar.getInstance().timeInMillis } diff --git a/course/src/main/java/org/openedx/course/presentation/home/AssignmentsHomePagerCardContent.kt b/course/src/main/java/org/openedx/course/presentation/home/AssignmentsHomePagerCardContent.kt new file mode 100644 index 000000000..39806992b --- /dev/null +++ b/course/src/main/java/org/openedx/course/presentation/home/AssignmentsHomePagerCardContent.kt @@ -0,0 +1,265 @@ +package org.openedx.course.presentation.home + +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.Card +import androidx.compose.material.Icon +import androidx.compose.material.LinearProgressIndicator +import androidx.compose.material.MaterialTheme +import androidx.compose.material.Text +import androidx.compose.material.TextButton +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.Assignment +import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight +import androidx.compose.material.icons.automirrored.filled.List +import androidx.compose.material.icons.filled.Timer +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import org.openedx.core.domain.model.Block +import org.openedx.core.ui.theme.appColors +import org.openedx.core.ui.theme.appTypography +import org.openedx.core.utils.TimeUtils +import org.openedx.course.R +import java.util.Date +import org.openedx.core.R as coreR + +@Composable +fun AssignmentsHomePagerCardContent( + uiState: CourseHomeUIState.CourseData, + onAssignmentClick: (Block) -> Unit, + onViewAllAssignmentsClick: () -> Unit +) { + val completedAssignments = uiState.courseAssignments.count { it.isCompleted() } + val totalAssignments = uiState.courseAssignments.size + val firstIncompleteAssignment = uiState.courseAssignments.find { !it.isCompleted() } + + Column( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp) + ) { + // Header with progress + Text( + text = stringResource(R.string.course_container_content_tab_assignment), + style = MaterialTheme.appTypography.titleLarge, + color = MaterialTheme.appColors.textPrimary, + fontWeight = FontWeight.SemiBold + ) + Spacer(modifier = Modifier.height(12.dp)) + + // Progress section + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + imageVector = Icons.AutoMirrored.Filled.Assignment, + contentDescription = null, + tint = MaterialTheme.appColors.textPrimary, + modifier = Modifier.size(32.dp) + ) + Spacer(modifier = Modifier.width(8.dp)) + Text( + text = "$completedAssignments/$totalAssignments", + style = MaterialTheme.appTypography.displaySmall, + color = MaterialTheme.appColors.textPrimary, + fontWeight = FontWeight.Bold + ) + Spacer(modifier = Modifier.width(8.dp)) + Text( + text = stringResource(R.string.course_assignments_completed), + style = MaterialTheme.appTypography.labelLarge, + color = MaterialTheme.appColors.textPrimaryVariant, + fontWeight = FontWeight.Medium + ) + } + + Spacer(modifier = Modifier.height(8.dp)) + + // Progress bar + LinearProgressIndicator( + modifier = Modifier + .fillMaxWidth() + .height(4.dp) + .clip(CircleShape), + progress = if (totalAssignments > 0) completedAssignments.toFloat() / totalAssignments else 0f, + color = MaterialTheme.appColors.progressBarColor, + backgroundColor = MaterialTheme.appColors.progressBarBackgroundColor + ) + + Spacer(modifier = Modifier.height(20.dp)) + + // First Incomplete Assignment section + if (firstIncompleteAssignment != null) { + AssignmentCard( + assignment = firstIncompleteAssignment, + onAssignmentClick = onAssignmentClick + ) + } + + Spacer(modifier = Modifier.height(8.dp)) + + // View All Assignments button + TextButton( + onClick = onViewAllAssignmentsClick, + modifier = Modifier.fillMaxWidth() + ) { + Icon( + imageVector = Icons.AutoMirrored.Filled.List, + contentDescription = null, + tint = MaterialTheme.appColors.primary, + modifier = Modifier.size(20.dp) + ) + Spacer(modifier = Modifier.width(8.dp)) + Text( + text = stringResource(R.string.course_view_all_assignments), + style = MaterialTheme.appTypography.labelLarge, + color = MaterialTheme.appColors.primary + ) + } + } +} + +@Composable +private fun AssignmentCard( + assignment: Block, + onAssignmentClick: (Block) -> Unit +) { + val isDuePast = assignment.due != null && assignment.due!! < Date() + + // Header text - "Past Due" or "Due Soon" + val headerText = if (isDuePast) { + stringResource(coreR.string.core_date_type_past_due) + } else { + stringResource(R.string.course_due_soon) + } + + // Due date status text + val dueDateStatusText = assignment.due?.let { due -> + val formattedDate = TimeUtils.formatToDayMonth(due) + val daysDifference = ((due.time - Date().time) / (1000 * 60 * 60 * 24)).toInt() + when { + daysDifference < 0 -> { + // Past due + val daysPastDue = -daysDifference + stringResource( + R.string.course_days_past_due, + daysPastDue, + formattedDate + ) + } + + daysDifference == 0 -> { + // Due today + stringResource( + R.string.course_due_today, + formattedDate + ) + } + + else -> { + // Due in the future + stringResource( + R.string.course_due_in_days, + daysDifference, + formattedDate + ) + } + } + } ?: "" + + Card( + modifier = Modifier + .fillMaxWidth() + .clickable { onAssignmentClick(assignment) }, + backgroundColor = MaterialTheme.appColors.surface, + border = BorderStroke(1.dp, MaterialTheme.appColors.cardViewBorder), + shape = RoundedCornerShape(8.dp), + elevation = 0.dp + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp) + ) { + // Header section with icon and status + if (assignment.due != null) { + Row( + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + imageVector = Icons.Filled.Timer, + contentDescription = null, + modifier = Modifier.size(24.dp), + tint = MaterialTheme.appColors.warning + ) + Spacer(modifier = Modifier.width(8.dp)) + Text( + text = headerText, + style = MaterialTheme.appTypography.titleMedium, + color = MaterialTheme.appColors.textPrimary, + fontWeight = FontWeight.Bold + ) + } + Spacer(modifier = Modifier.height(8.dp)) + } + + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically + ) { + Column( + modifier = Modifier.weight(1f) + ) { + // Due date status text + if (dueDateStatusText.isNotEmpty()) { + Text( + text = dueDateStatusText, + style = MaterialTheme.appTypography.labelSmall, + color = MaterialTheme.appColors.primary + ) + Spacer(modifier = Modifier.height(4.dp)) + } + + + // Assignment and section name + Text( + text = assignment.assignmentProgress?.assignmentType ?: "", + style = MaterialTheme.appTypography.titleSmall, + color = MaterialTheme.appColors.textPrimary, + fontWeight = FontWeight.Bold + ) + Spacer(modifier = Modifier.height(4.dp)) + Text( + text = assignment.displayName, + style = MaterialTheme.appTypography.labelSmall, + color = MaterialTheme.appColors.textPrimaryVariant, + ) + } + + // Chevron arrow + Icon( + imageVector = Icons.AutoMirrored.Filled.KeyboardArrowRight, + contentDescription = null, + modifier = Modifier.size(24.dp), + tint = MaterialTheme.appColors.textDark + ) + } + } + } +} diff --git a/course/src/main/java/org/openedx/course/presentation/home/CourseCompletionHomePagerCardContent.kt b/course/src/main/java/org/openedx/course/presentation/home/CourseCompletionHomePagerCardContent.kt index 095258242..3db0b0be3 100644 --- a/course/src/main/java/org/openedx/course/presentation/home/CourseCompletionHomePagerCardContent.kt +++ b/course/src/main/java/org/openedx/course/presentation/home/CourseCompletionHomePagerCardContent.kt @@ -165,6 +165,7 @@ private fun CourseCompletionHomePagerCardContentPreview() { ), useRelativeDates = true, courseVideos = mapOf(), + courseAssignments = emptyList(), videoPreview = null, videoProgress = 0f ), diff --git a/course/src/main/java/org/openedx/course/presentation/home/CourseHomeScreen.kt b/course/src/main/java/org/openedx/course/presentation/home/CourseHomeScreen.kt index 3a37c2cca..f682abd76 100644 --- a/course/src/main/java/org/openedx/course/presentation/home/CourseHomeScreen.kt +++ b/course/src/main/java/org/openedx/course/presentation/home/CourseHomeScreen.kt @@ -149,6 +149,15 @@ fun CourseHomeScreen( ) viewModel.logVideoClick(videoBlock.id) }, + onAssignmentClick = { assignmentBlock -> + viewModel.courseRouter.navigateToCourseContainer( + fragmentManager, + courseId = viewModel.courseId, + unitId = viewModel.getBlockParent(assignmentBlock.id)?.id ?: return@CourseHomeUI, + mode = CourseViewMode.FULL + ) + viewModel.logAssignmentClick(assignmentBlock.id) + }, onNavigateToContent = onNavigateToContent ) } @@ -165,6 +174,7 @@ private fun CourseHomeUI( onResetDatesClick: () -> Unit, onCertificateClick: (String) -> Unit, onVideoClick: (Block) -> Unit, + onAssignmentClick: (Block) -> Unit, onNavigateToContent: (CourseContentTab) -> Unit, ) { val scaffoldState = rememberScaffoldState() @@ -296,6 +306,16 @@ private fun CourseHomeUI( ) } + CourseHomePagerTab.ASSIGNMENT -> { + AssignmentsHomePagerCardContent( + uiState = uiState, + onAssignmentClick = onAssignmentClick, + onViewAllAssignmentsClick = { + onNavigateToContent(CourseContentTab.ASSIGNMENTS) + } + ) + } + else -> { Text(tab.name) } @@ -367,6 +387,7 @@ private fun CourseHomeScreenPreview() { ), useRelativeDates = true, courseVideos = mapOf(), + courseAssignments = emptyList(), videoPreview = null, videoProgress = 0f ), @@ -378,6 +399,7 @@ private fun CourseHomeScreenPreview() { onResetDatesClick = {}, onCertificateClick = {}, onVideoClick = {}, + onAssignmentClick = {}, onNavigateToContent = { _ -> }, ) } @@ -412,6 +434,7 @@ private fun CourseHomeScreenTabletPreview() { ), useRelativeDates = true, courseVideos = mapOf(), + courseAssignments = emptyList(), videoPreview = null, videoProgress = 0f ), @@ -423,6 +446,7 @@ private fun CourseHomeScreenTabletPreview() { onResetDatesClick = {}, onCertificateClick = {}, onVideoClick = {}, + onAssignmentClick = {}, onNavigateToContent = { _ -> }, ) } diff --git a/course/src/main/java/org/openedx/course/presentation/home/CourseHomeUIState.kt b/course/src/main/java/org/openedx/course/presentation/home/CourseHomeUIState.kt index 78bd5a749..cd2d6f83a 100644 --- a/course/src/main/java/org/openedx/course/presentation/home/CourseHomeUIState.kt +++ b/course/src/main/java/org/openedx/course/presentation/home/CourseHomeUIState.kt @@ -20,6 +20,7 @@ sealed class CourseHomeUIState { val datesBannerInfo: CourseDatesBannerInfo, val useRelativeDates: Boolean, val courseVideos: Map>, + val courseAssignments: List, val videoPreview: VideoPreview?, val videoProgress: Float, ) : CourseHomeUIState() diff --git a/course/src/main/java/org/openedx/course/presentation/home/CourseHomeViewModel.kt b/course/src/main/java/org/openedx/course/presentation/home/CourseHomeViewModel.kt index 6b9578120..281e26780 100644 --- a/course/src/main/java/org/openedx/course/presentation/home/CourseHomeViewModel.kt +++ b/course/src/main/java/org/openedx/course/presentation/home/CourseHomeViewModel.kt @@ -97,6 +97,7 @@ class CourseHomeViewModel( private val subSectionsDownloadsCount = mutableMapOf() val courseSubSectionUnit = mutableMapOf() private val courseVideos = mutableMapOf>() + private val courseAssignments = mutableListOf() init { viewModelScope.launch { @@ -135,6 +136,7 @@ class CourseHomeViewModel( next = state.next, courseProgress = state.courseProgress, courseVideos = state.courseVideos, + courseAssignments = courseAssignments, videoPreview = state.videoPreview, videoProgress = state.videoProgress ) @@ -208,6 +210,18 @@ class CourseHomeViewModel( courseSubSections.clear() courseSubSectionUnit.clear() courseVideos.clear() + courseAssignments.clear() + + // Collect all assignments from the original blocks + val allAssignments = blocks + .filter { !it.assignmentProgress?.assignmentType.isNullOrEmpty() } + .filter { it.graded } + .sortedWith( + compareBy { it.due == null } + .thenBy { it.due } + ) + courseAssignments.addAll(allAssignments) + val sortedStructure = courseStructure.copy(blockData = sortBlocks(blocks)) initDownloadModelsStatus() val nextSection = findFirstChapterWithIncompleteDescendants(blocks) @@ -226,7 +240,7 @@ class CourseHomeViewModel( val progress = videoProgressEntity.videoTime.toFloat() / videoProgressEntity.duration.toFloat() progress.coerceIn(0f, 1f) - } catch (e: Exception) { + } catch (_: Exception) { 0f } } else { @@ -245,6 +259,7 @@ class CourseHomeViewModel( useRelativeDates = preferencesManager.isRelativeDatesEnabled, courseProgress = courseProgress, courseVideos = courseVideos, + courseAssignments = courseAssignments, videoPreview = videoPreview, videoProgress = videoProgress ) @@ -550,4 +565,21 @@ class CourseHomeViewModel( ) } } + + fun logAssignmentClick(blockId: String) { + val currentState = uiState.value + if (currentState is CourseHomeUIState.CourseData) { + analytics.logEvent( + CourseAnalyticsEvent.COURSE_CONTENT_ASSIGNMENT_CLICK.eventName, + buildMap { + put( + CourseAnalyticsKey.NAME.key, + CourseAnalyticsEvent.COURSE_CONTENT_ASSIGNMENT_CLICK.biValue + ) + put(CourseAnalyticsKey.COURSE_ID.key, courseId) + put(CourseAnalyticsKey.BLOCK_ID.key, blockId) + } + ) + } + } } diff --git a/course/src/main/res/values/strings.xml b/course/src/main/res/values/strings.xml index 0080e8a1b..e4d8dbcca 100644 --- a/course/src/main/res/values/strings.xml +++ b/course/src/main/res/values/strings.xml @@ -95,4 +95,13 @@ View All Videos %1$s left Videos\ncompleted + + + Assignments\ncompleted + View All Assignments + Due Soon + Due Today: %1$s + %1$d Days Past Due: %2$s + Due in %1$d Days: %2$s + Section: %1$s From 87da809ac2f489a92a57f899ee8f9f9bb27008ae Mon Sep 17 00:00:00 2001 From: PavloNetrebchuk Date: Thu, 4 Sep 2025 14:49:19 +0300 Subject: [PATCH 06/17] feat: course home pages grades card --- .../container/CourseContainerFragment.kt | 11 +- .../home/AssignmentsHomePagerCardContent.kt | 23 +- .../CourseCompletionHomePagerCardContent.kt | 29 +-- .../presentation/home/CourseHomeScreen.kt | 46 +++- .../home/GradesHomePagerCardContent.kt | 202 +++++++++++++++ .../home/VideosHomePagerCardContent.kt | 23 +- .../progress/CourseProgressScreen.kt | 230 ++++++++++-------- course/src/main/res/values/strings.xml | 5 + 8 files changed, 399 insertions(+), 170 deletions(-) create mode 100644 course/src/main/java/org/openedx/course/presentation/home/GradesHomePagerCardContent.kt 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 0cf27b1b0..f5c8a66ff 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 @@ -489,6 +489,13 @@ private fun DashboardPager( CourseContentTab.entries.indexOf(contentTab) ) } + }, + onNavigateToProgress = { + scope.launch { + pagerState.animateScrollToPage( + CourseContainerTab.entries.indexOf(CourseContainerTab.PROGRESS) + ) + } } ) } @@ -717,7 +724,9 @@ private fun scrollToProgress(scope: CoroutineScope, pagerState: PagerState) { private fun HomeNavigationRow(homePagerState: PagerState) { val homeCoroutineScope = rememberCoroutineScope() Row( - modifier = Modifier.fillMaxWidth(), + modifier = Modifier + .fillMaxWidth() + .background(MaterialTheme.appColors.background), horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically ) { diff --git a/course/src/main/java/org/openedx/course/presentation/home/AssignmentsHomePagerCardContent.kt b/course/src/main/java/org/openedx/course/presentation/home/AssignmentsHomePagerCardContent.kt index 39806992b..2bb83e461 100644 --- a/course/src/main/java/org/openedx/course/presentation/home/AssignmentsHomePagerCardContent.kt +++ b/course/src/main/java/org/openedx/course/presentation/home/AssignmentsHomePagerCardContent.kt @@ -17,11 +17,9 @@ import androidx.compose.material.Icon import androidx.compose.material.LinearProgressIndicator import androidx.compose.material.MaterialTheme import androidx.compose.material.Text -import androidx.compose.material.TextButton import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.Assignment import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight -import androidx.compose.material.icons.automirrored.filled.List import androidx.compose.material.icons.filled.Timer import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment @@ -115,23 +113,10 @@ fun AssignmentsHomePagerCardContent( Spacer(modifier = Modifier.height(8.dp)) // View All Assignments button - TextButton( - onClick = onViewAllAssignmentsClick, - modifier = Modifier.fillMaxWidth() - ) { - Icon( - imageVector = Icons.AutoMirrored.Filled.List, - contentDescription = null, - tint = MaterialTheme.appColors.primary, - modifier = Modifier.size(20.dp) - ) - Spacer(modifier = Modifier.width(8.dp)) - Text( - text = stringResource(R.string.course_view_all_assignments), - style = MaterialTheme.appTypography.labelLarge, - color = MaterialTheme.appColors.primary - ) - } + ViewAllButton( + text = stringResource(R.string.course_view_all_assignments), + onClick = onViewAllAssignmentsClick + ) } } diff --git a/course/src/main/java/org/openedx/course/presentation/home/CourseCompletionHomePagerCardContent.kt b/course/src/main/java/org/openedx/course/presentation/home/CourseCompletionHomePagerCardContent.kt index 3db0b0be3..a655b1f68 100644 --- a/course/src/main/java/org/openedx/course/presentation/home/CourseCompletionHomePagerCardContent.kt +++ b/course/src/main/java/org/openedx/course/presentation/home/CourseCompletionHomePagerCardContent.kt @@ -7,14 +7,8 @@ import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.width -import androidx.compose.material.Icon import androidx.compose.material.MaterialTheme import androidx.compose.material.Text -import androidx.compose.material.TextButton -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.automirrored.filled.List import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -117,28 +111,11 @@ fun CourseCompletionHomePagerCardContent( Spacer(modifier = Modifier.height(8.dp)) // View All Content Button - TextButton( + ViewAllButton( + text = stringResource(R.string.course_completion_view_all_content), onClick = onViewAllContentClick, modifier = Modifier.align(Alignment.CenterHorizontally) - ) { - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.Center - ) { - Icon( - imageVector = Icons.AutoMirrored.Filled.List, - contentDescription = null, - modifier = Modifier.size(20.dp), - tint = MaterialTheme.appColors.primary - ) - Spacer(modifier = Modifier.width(8.dp)) - Text( - text = stringResource(R.string.course_completion_view_all_content), - style = MaterialTheme.appTypography.labelLarge, - color = MaterialTheme.appColors.primary - ) - } - } + ) } } diff --git a/course/src/main/java/org/openedx/course/presentation/home/CourseHomeScreen.kt b/course/src/main/java/org/openedx/course/presentation/home/CourseHomeScreen.kt index f682abd76..bc6d4408d 100644 --- a/course/src/main/java/org/openedx/course/presentation/home/CourseHomeScreen.kt +++ b/course/src/main/java/org/openedx/course/presentation/home/CourseHomeScreen.kt @@ -11,6 +11,8 @@ import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height 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.pager.HorizontalPager import androidx.compose.foundation.pager.PagerState @@ -18,10 +20,14 @@ import androidx.compose.foundation.pager.rememberPagerState import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll import androidx.compose.material.Card +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.TextButton +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.List import androidx.compose.material.rememberScaffoldState import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect @@ -52,6 +58,7 @@ import org.openedx.core.ui.displayCutoutForLandscape import org.openedx.core.ui.theme.OpenEdXTheme 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.course.presentation.container.CourseContentTab import org.openedx.course.presentation.ui.CourseDatesBanner @@ -72,6 +79,7 @@ fun CourseHomeScreen( homePagerState: PagerState, onResetDatesClick: () -> Unit, onNavigateToContent: (CourseContentTab) -> Unit = {}, + onNavigateToProgress: () -> Unit = {}, ) { val uiState by viewModel.uiState.collectAsState() val uiMessage by viewModel.uiMessage.collectAsState(null) @@ -158,7 +166,8 @@ fun CourseHomeScreen( ) viewModel.logAssignmentClick(assignmentBlock.id) }, - onNavigateToContent = onNavigateToContent + onNavigateToContent = onNavigateToContent, + onNavigateToProgress = onNavigateToProgress ) } @@ -176,6 +185,7 @@ private fun CourseHomeUI( onVideoClick: (Block) -> Unit, onAssignmentClick: (Block) -> Unit, onNavigateToContent: (CourseContentTab) -> Unit, + onNavigateToProgress: () -> Unit, ) { val scaffoldState = rememberScaffoldState() @@ -316,8 +326,11 @@ private fun CourseHomeUI( ) } - else -> { - Text(tab.name) + CourseHomePagerTab.GRADES -> { + GradesHomePagerCardContent( + uiState = uiState, + onViewProgressClick = onNavigateToProgress + ) } } } @@ -401,6 +414,7 @@ private fun CourseHomeScreenPreview() { onVideoClick = {}, onAssignmentClick = {}, onNavigateToContent = { _ -> }, + onNavigateToProgress = {} ) } } @@ -448,6 +462,32 @@ private fun CourseHomeScreenTabletPreview() { onVideoClick = {}, onAssignmentClick = {}, onNavigateToContent = { _ -> }, + onNavigateToProgress = { }, + ) + } +} + +@Composable +fun ViewAllButton( + text: String, + onClick: () -> Unit, + modifier: Modifier = Modifier, +) { + TextButton( + onClick = onClick, + modifier = modifier.fillMaxWidth() + ) { + Icon( + imageVector = Icons.AutoMirrored.Filled.List, + contentDescription = null, + tint = MaterialTheme.appColors.primary, + modifier = Modifier.size(20.dp) + ) + Spacer(modifier = Modifier.width(8.dp)) + Text( + text = text, + style = MaterialTheme.appTypography.labelLarge, + color = MaterialTheme.appColors.primary ) } } diff --git a/course/src/main/java/org/openedx/course/presentation/home/GradesHomePagerCardContent.kt b/course/src/main/java/org/openedx/course/presentation/home/GradesHomePagerCardContent.kt new file mode 100644 index 000000000..80b1ac714 --- /dev/null +++ b/course/src/main/java/org/openedx/course/presentation/home/GradesHomePagerCardContent.kt @@ -0,0 +1,202 @@ +package org.openedx.course.presentation.home + +import androidx.compose.foundation.background +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.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material.Card +import androidx.compose.material.MaterialTheme +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import org.openedx.core.domain.model.CourseProgress +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.course.presentation.progress.CurrentOverallGradeText +import org.openedx.course.presentation.progress.GradeProgressBar +import org.openedx.course.presentation.progress.RequiredGradeMarker + +@Composable +fun GradesHomePagerCardContent( + uiState: CourseHomeUIState.CourseData, + onViewProgressClick: () -> Unit +) { + val courseProgress = uiState.courseProgress + val gradingPolicy = courseProgress?.gradingPolicy + + Column( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp) + ) { + Text( + text = stringResource(R.string.course_grades_title), + style = MaterialTheme.appTypography.titleLarge, + color = MaterialTheme.appColors.textPrimary, + fontWeight = FontWeight.SemiBold + ) + Spacer(modifier = Modifier.height(16.dp)) + Text( + text = stringResource(R.string.course_grades_description), + style = MaterialTheme.appTypography.labelMedium, + color = MaterialTheme.appColors.textPrimaryVariant, + ) + Spacer(modifier = Modifier.height(16.dp)) + if (courseProgress != null && gradingPolicy != null) { + CurrentOverallGradeText(progress = courseProgress) + Spacer(modifier = Modifier.height(12.dp)) + GradeProgressBar( + progress = courseProgress, + gradingPolicy = gradingPolicy, + notCompletedWeightedGradePercent = courseProgress.getNotCompletedWeightedGradePercent() + ) + RequiredGradeMarker(progress = courseProgress) + Spacer(modifier = Modifier.height(20.dp)) + GradeCardsGrid( + assignmentPolicies = gradingPolicy.assignmentPolicies, + assignmentColors = gradingPolicy.assignmentColors, + progress = courseProgress + ) + Spacer(modifier = Modifier.height(8.dp)) + ViewAllButton( + text = stringResource(R.string.course_view_progress), + onClick = onViewProgressClick, + ) + } else { + Text( + text = stringResource(R.string.course_progress_no_assignments), + style = MaterialTheme.appTypography.labelMedium, + color = MaterialTheme.appColors.textPrimaryVariant, + ) + } + } +} + +@Composable +private fun GradeCard( + policy: CourseProgress.GradingPolicy.AssignmentPolicy, + progress: CourseProgress, + color: Color, + modifier: Modifier = Modifier +) { + val earned = progress.getEarnedAssignmentProblems(policy) + val possible = progress.getPossibleAssignmentProblems(policy) + val gradePercent = if (possible > 0) (earned.toFloat() / possible * 100).toInt() else 0 + + Card( + modifier = modifier.fillMaxWidth(), + backgroundColor = color.copy(alpha = 0.1f), + shape = MaterialTheme.appShapes.material.small, + elevation = 0.dp, + ) { + Column( + modifier = Modifier + .padding(horizontal = 16.dp) + .padding(vertical = 10.dp) + ) { + // Assignment type title + Text( + text = policy.type, + style = MaterialTheme.appTypography.labelLarge, + color = MaterialTheme.appColors.textPrimary, + fontWeight = FontWeight.Medium, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + + Spacer(modifier = Modifier.height(8.dp)) + + // Grade percentage with colored bar + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .fillMaxWidth() + .height(IntrinsicSize.Min), + ) { + Box( + modifier = Modifier + .width(8.dp) + .fillMaxHeight() + .background( + color = color, + shape = CircleShape + ) + ) + Spacer(modifier = Modifier.width(8.dp)) + Column { + Text( + text = if (possible > 0) "$gradePercent%" else "--%", + style = MaterialTheme.appTypography.bodyLarge, + color = MaterialTheme.appColors.textPrimary, + fontWeight = FontWeight.Bold + ) + Spacer(modifier = Modifier.height(4.dp)) + Text( + text = stringResource( + R.string.course_progress_earned_possible_assignment_problems, + earned.toInt(), + possible.toInt() + ), + style = MaterialTheme.appTypography.labelSmall, + color = MaterialTheme.appColors.textPrimary, + ) + } + } + } + } +} + +@Composable +private fun GradeCardsGrid( + assignmentPolicies: List, + assignmentColors: List, + progress: CourseProgress +) { + Column( + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + // Group policies into rows of 2 + assignmentPolicies.chunked(2).forEach { rowPolicies -> + Row( + horizontalArrangement = Arrangement.spacedBy(16.dp), + modifier = Modifier.fillMaxWidth() + ) { + rowPolicies.forEachIndexed { index, policy -> + val policyIndex = assignmentPolicies.indexOf(policy) + GradeCard( + policy = policy, + progress = progress, + color = if (assignmentColors.isNotEmpty()) { + assignmentColors[policyIndex % assignmentColors.size] + } else { + MaterialTheme.appColors.primary + }, + modifier = Modifier.weight(1f) + ) + } + // Fill remaining space if row has only 1 item + if (rowPolicies.size == 1) { + Spacer(modifier = Modifier.weight(1f)) + } + } + } + } +} + diff --git a/course/src/main/java/org/openedx/course/presentation/home/VideosHomePagerCardContent.kt b/course/src/main/java/org/openedx/course/presentation/home/VideosHomePagerCardContent.kt index 37e369c5e..93d42d058 100644 --- a/course/src/main/java/org/openedx/course/presentation/home/VideosHomePagerCardContent.kt +++ b/course/src/main/java/org/openedx/course/presentation/home/VideosHomePagerCardContent.kt @@ -14,9 +14,7 @@ import androidx.compose.material.Icon import androidx.compose.material.LinearProgressIndicator import androidx.compose.material.MaterialTheme import androidx.compose.material.Text -import androidx.compose.material.TextButton import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.automirrored.filled.List import androidx.compose.material.icons.filled.Videocam import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment @@ -129,22 +127,9 @@ fun VideosHomePagerCardContent( Spacer(modifier = Modifier.height(8.dp)) // View All Videos button - TextButton( - onClick = onViewAllVideosClick, - modifier = Modifier.fillMaxWidth() - ) { - Icon( - imageVector = Icons.AutoMirrored.Filled.List, - contentDescription = null, - tint = MaterialTheme.appColors.primary, - modifier = Modifier.size(20.dp) - ) - Spacer(modifier = Modifier.width(8.dp)) - Text( - text = stringResource(R.string.course_view_all_videos), - style = MaterialTheme.appTypography.labelLarge, - color = MaterialTheme.appColors.primary - ) - } + ViewAllButton( + text = stringResource(R.string.course_view_all_videos), + onClick = onViewAllVideosClick + ) } } 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 240be2521..bb04c3d90 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 @@ -234,7 +234,7 @@ private fun GradeDetailsHeaderView() { } @Composable -private fun GradeDetailsFooterView( +fun GradeDetailsFooterView( progress: CourseProgress, ) { Row( @@ -276,108 +276,14 @@ private fun OverallGradeView( style = MaterialTheme.appTypography.labelMedium, color = MaterialTheme.appColors.textDark, ) - Text( - text = buildAnnotatedString { - withStyle( - style = SpanStyle( - color = MaterialTheme.appColors.textDark, - fontSize = MaterialTheme.appTypography.labelMedium.fontSize, - fontFamily = MaterialTheme.appTypography.labelMedium.fontFamily, - fontWeight = MaterialTheme.appTypography.labelMedium.fontWeight - ) - ) { - append(stringResource(R.string.course_progress_current_overall) + " ") - } - withStyle( - style = SpanStyle( - color = MaterialTheme.appColors.primary, - fontSize = MaterialTheme.appTypography.labelMedium.fontSize, - fontFamily = MaterialTheme.appTypography.labelMedium.fontFamily, - fontWeight = FontWeight.SemiBold - ) - ) { - append("${progress.getTotalWeightPercent().toInt()}%") - } - }, - style = MaterialTheme.appTypography.labelMedium, - ) - + CurrentOverallGradeText(progress = progress) Column { - Row( - modifier = Modifier - .fillMaxWidth() - .height(8.dp) - .clip(CircleShape) - .border( - width = 1.dp, - color = MaterialTheme.appColors.gradeProgressBarBorder, - shape = CircleShape - ) - ) { - gradingPolicy.assignmentPolicies.forEachIndexed { index, assignmentPolicy -> - val assignmentColors = gradingPolicy.assignmentColors - val color = if (assignmentColors.isNotEmpty()) { - assignmentColors[ - gradingPolicy.assignmentPolicies.indexOf( - assignmentPolicy - ) % assignmentColors.size - ] - } else { - MaterialTheme.appColors.primary - } - val weightedPercent = - progress.getAssignmentWeightedGradedPercent(assignmentPolicy) - if (weightedPercent > 0f) { - Box( - modifier = Modifier - .weight(weightedPercent) - .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) { - Box( - modifier = Modifier - .weight(notCompletedWeightedGradePercent) - .fillMaxHeight() - ) - } - } - Box( - modifier = Modifier - .fillMaxWidth(progress.requiredGrade), - contentAlignment = Alignment.CenterEnd - ) { - Box( - modifier = Modifier.offset(x = 20.dp), - contentAlignment = Alignment.Center - ) { - Icon( - painter = painterResource(id = R.drawable.ic_course_marker), - tint = MaterialTheme.appColors.warning, - contentDescription = null - ) - Text( - modifier = Modifier - .offset(y = 2.dp) - .clearAndSetSemantics { }, - text = "${progress.requiredGradePercent}%", - style = MaterialTheme.appTypography.labelMedium, - color = MaterialTheme.appColors.textDark, - ) - } - } + GradeProgressBar( + progress = progress, + gradingPolicy = gradingPolicy, + notCompletedWeightedGradePercent = notCompletedWeightedGradePercent + ) + RequiredGradeMarker(progress = progress) } Surface( @@ -558,3 +464,123 @@ fun CourseCompletionCircularProgress( } } } + +@Composable +fun GradeProgressBar( + progress: CourseProgress, + gradingPolicy: CourseProgress.GradingPolicy, + notCompletedWeightedGradePercent: Float +) { + Row( + modifier = Modifier + .fillMaxWidth() + .height(8.dp) + .clip(CircleShape) + .border( + width = 1.dp, + color = MaterialTheme.appColors.gradeProgressBarBorder, + shape = CircleShape + ) + ) { + gradingPolicy.assignmentPolicies.forEachIndexed { index, assignmentPolicy -> + val assignmentColors = gradingPolicy.assignmentColors + val color = if (assignmentColors.isNotEmpty()) { + assignmentColors[ + gradingPolicy.assignmentPolicies.indexOf( + assignmentPolicy + ) % assignmentColors.size + ] + } else { + MaterialTheme.appColors.primary + } + val weightedPercent = + progress.getAssignmentWeightedGradedPercent(assignmentPolicy) + if (weightedPercent > 0f) { + Box( + modifier = Modifier + .weight(weightedPercent) + .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) { + Box( + modifier = Modifier + .weight(notCompletedWeightedGradePercent) + .fillMaxHeight() + ) + } + } +} + +@Composable +fun RequiredGradeMarker( + progress: CourseProgress +) { + Box( + modifier = Modifier + .fillMaxWidth(progress.requiredGrade), + contentAlignment = Alignment.CenterEnd + ) { + Box( + modifier = Modifier.offset(x = 20.dp), + contentAlignment = Alignment.Center + ) { + Icon( + painter = painterResource(id = R.drawable.ic_course_marker), + tint = MaterialTheme.appColors.warning, + contentDescription = null + ) + Text( + modifier = Modifier + .offset(y = 2.dp) + .clearAndSetSemantics { }, + text = "${progress.requiredGradePercent}%", + style = MaterialTheme.appTypography.labelMedium, + color = MaterialTheme.appColors.textDark, + ) + } + } +} + +@Composable +fun CurrentOverallGradeText( + progress: CourseProgress +) { + Text( + text = buildAnnotatedString { + withStyle( + style = SpanStyle( + color = MaterialTheme.appColors.textDark, + fontSize = MaterialTheme.appTypography.labelMedium.fontSize, + fontFamily = MaterialTheme.appTypography.labelMedium.fontFamily, + fontWeight = MaterialTheme.appTypography.labelMedium.fontWeight + ) + ) { + append(stringResource(R.string.course_progress_current_overall) + " ") + } + withStyle( + style = SpanStyle( + color = MaterialTheme.appColors.primary, + fontSize = MaterialTheme.appTypography.labelMedium.fontSize, + fontFamily = MaterialTheme.appTypography.labelMedium.fontFamily, + fontWeight = FontWeight.SemiBold + ) + ) { + append("${progress.getTotalWeightPercent().toInt()}%") + } + }, + style = MaterialTheme.appTypography.labelMedium, + ) +} diff --git a/course/src/main/res/values/strings.xml b/course/src/main/res/values/strings.xml index e4d8dbcca..3082b880a 100644 --- a/course/src/main/res/values/strings.xml +++ b/course/src/main/res/values/strings.xml @@ -104,4 +104,9 @@ %1$d Days Past Due: %2$s Due in %1$d Days: %2$s Section: %1$s + + + Grades + This represents your weighted grade against the grade needed to pass this course. + View Progress From 2ce9cebb57543711f5b81ab776a39356ae972fe4 Mon Sep 17 00:00:00 2001 From: PavloNetrebchuk Date: Thu, 4 Sep 2025 16:13:20 +0300 Subject: [PATCH 07/17] feat: added empty states --- .../CourseContentAssignmentScreen.kt | 3 + .../contenttab/ContentTabEmptyState.kt | 89 +++++--- .../home/AssignmentsHomePagerCardContent.kt | 9 + .../presentation/home/CourseHomeScreen.kt | 204 +++++++++--------- .../home/GradesHomePagerCardContent.kt | 52 +++-- .../home/VideosHomePagerCardContent.kt | 10 + .../outline/CourseContentAllScreen.kt | 3 + .../videos/CourseContentVideoScreen.kt | 3 + 8 files changed, 214 insertions(+), 159 deletions(-) diff --git a/course/src/main/java/org/openedx/course/presentation/assignments/CourseContentAssignmentScreen.kt b/course/src/main/java/org/openedx/course/presentation/assignments/CourseContentAssignmentScreen.kt index 57d2d5766..95ed45003 100644 --- a/course/src/main/java/org/openedx/course/presentation/assignments/CourseContentAssignmentScreen.kt +++ b/course/src/main/java/org/openedx/course/presentation/assignments/CourseContentAssignmentScreen.kt @@ -21,6 +21,8 @@ import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyRow import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll import androidx.compose.material.Card import androidx.compose.material.CircularProgressIndicator import androidx.compose.material.Divider @@ -132,6 +134,7 @@ private fun CourseContentAssignmentScreen( is CourseAssignmentUIState.Empty -> { CourseContentAssignmentEmptyState( + modifier = Modifier.verticalScroll(rememberScrollState()), onReturnToCourseClick = onNavigateToHome ) } diff --git a/course/src/main/java/org/openedx/course/presentation/contenttab/ContentTabEmptyState.kt b/course/src/main/java/org/openedx/course/presentation/contenttab/ContentTabEmptyState.kt index e5926b315..315b817ad 100644 --- a/course/src/main/java/org/openedx/course/presentation/contenttab/ContentTabEmptyState.kt +++ b/course/src/main/java/org/openedx/course/presentation/contenttab/ContentTabEmptyState.kt @@ -8,8 +8,6 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size -import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.verticalScroll import androidx.compose.material.Icon import androidx.compose.material.MaterialTheme import androidx.compose.material.Text @@ -34,15 +32,16 @@ import org.openedx.course.R @Composable fun ContentTabEmptyState( + modifier: Modifier = Modifier, message: String, - onReturnToCourseClick: () -> Unit + onReturnToCourseClick: () -> Unit, + showReturnButton: Boolean = true ) { val configuration = LocalConfiguration.current Column( - modifier = Modifier + modifier = modifier .fillMaxWidth() - .padding(vertical = 24.dp) - .verticalScroll(rememberScrollState()), + .padding(vertical = 24.dp), verticalArrangement = Arrangement.Center, horizontalAlignment = Alignment.CenterHorizontally ) { @@ -64,52 +63,78 @@ fun ContentTabEmptyState( fontWeight = FontWeight.Medium, textAlign = TextAlign.Center ) - Spacer(Modifier.height(16.dp)) - OpenEdXButton( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 24.dp), - textColor = MaterialTheme.appColors.secondaryButtonText, - backgroundColor = MaterialTheme.appColors.secondaryButtonBackground, - onClick = onReturnToCourseClick - ) { - IconText( - text = stringResource(id = R.string.course_return_to_course_home), - icon = Icons.AutoMirrored.Filled.ArrowBack, - color = MaterialTheme.appColors.secondaryButtonText, - textStyle = MaterialTheme.appTypography.labelLarge - ) + if (showReturnButton) { + Spacer(Modifier.height(16.dp)) + OpenEdXButton( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 24.dp), + textColor = MaterialTheme.appColors.secondaryButtonText, + backgroundColor = MaterialTheme.appColors.secondaryButtonBackground, + onClick = onReturnToCourseClick + ) { + IconText( + text = stringResource(id = R.string.course_return_to_course_home), + icon = Icons.AutoMirrored.Filled.ArrowBack, + color = MaterialTheme.appColors.secondaryButtonText, + textStyle = MaterialTheme.appTypography.labelLarge + ) + } } } } @Composable fun CourseContentAllEmptyState( - onReturnToCourseClick: () -> Unit + modifier: Modifier = Modifier, + onReturnToCourseClick: () -> Unit, + showReturnButton: Boolean = true ) { ContentTabEmptyState( + modifier = modifier, message = stringResource(id = org.openedx.core.R.string.core_no_course_content), - onReturnToCourseClick = onReturnToCourseClick + onReturnToCourseClick = onReturnToCourseClick, + showReturnButton = showReturnButton ) } @Composable fun CourseContentVideoEmptyState( - onReturnToCourseClick: () -> Unit + modifier: Modifier = Modifier, + onReturnToCourseClick: () -> Unit, + showReturnButton: Boolean = true ) { ContentTabEmptyState( + modifier = modifier, message = stringResource(id = org.openedx.core.R.string.core_no_videos), - onReturnToCourseClick = onReturnToCourseClick + onReturnToCourseClick = onReturnToCourseClick, + showReturnButton = showReturnButton ) } @Composable fun CourseContentAssignmentEmptyState( - onReturnToCourseClick: () -> Unit + modifier: Modifier = Modifier, + onReturnToCourseClick: () -> Unit, + showReturnButton: Boolean = true ) { ContentTabEmptyState( + modifier = modifier, message = stringResource(id = org.openedx.core.R.string.core_no_assignments), - onReturnToCourseClick = onReturnToCourseClick + onReturnToCourseClick = onReturnToCourseClick, + showReturnButton = showReturnButton + ) +} + +@Composable +fun CourseHomeGradesEmptyState( + modifier: Modifier = Modifier, +) { + ContentTabEmptyState( + modifier = modifier, + message = stringResource(id = R.string.course_progress_no_assignments), + onReturnToCourseClick = {}, + showReturnButton = false ) } @@ -117,6 +142,14 @@ fun CourseContentAssignmentEmptyState( @Composable private fun CourseContentAllEmptyStatePreview() { OpenEdXTheme { - CourseContentAllEmptyState({}) + CourseContentAllEmptyState(onReturnToCourseClick = {}) + } +} + +@Preview +@Composable +private fun CourseHomeGradesEmptyStatePreview() { + OpenEdXTheme { + CourseHomeGradesEmptyState() } } diff --git a/course/src/main/java/org/openedx/course/presentation/home/AssignmentsHomePagerCardContent.kt b/course/src/main/java/org/openedx/course/presentation/home/AssignmentsHomePagerCardContent.kt index 2bb83e461..431e65d42 100644 --- a/course/src/main/java/org/openedx/course/presentation/home/AssignmentsHomePagerCardContent.kt +++ b/course/src/main/java/org/openedx/course/presentation/home/AssignmentsHomePagerCardContent.kt @@ -33,6 +33,7 @@ import org.openedx.core.ui.theme.appColors import org.openedx.core.ui.theme.appTypography import org.openedx.core.utils.TimeUtils import org.openedx.course.R +import org.openedx.course.presentation.contenttab.CourseContentAssignmentEmptyState import java.util.Date import org.openedx.core.R as coreR @@ -42,6 +43,14 @@ fun AssignmentsHomePagerCardContent( onAssignmentClick: (Block) -> Unit, onViewAllAssignmentsClick: () -> Unit ) { + if (uiState.courseAssignments.isEmpty()) { + CourseContentAssignmentEmptyState( + onReturnToCourseClick = {}, + showReturnButton = false + ) + return + } + val completedAssignments = uiState.courseAssignments.count { it.isCompleted() } val totalAssignments = uiState.courseAssignments.size val firstIncompleteAssignment = uiState.courseAssignments.find { !it.isCompleted() } diff --git a/course/src/main/java/org/openedx/course/presentation/home/CourseHomeScreen.kt b/course/src/main/java/org/openedx/course/presentation/home/CourseHomeScreen.kt index bc6d4408d..f84bfd332 100644 --- a/course/src/main/java/org/openedx/course/presentation/home/CourseHomeScreen.kt +++ b/course/src/main/java/org/openedx/course/presentation/home/CourseHomeScreen.kt @@ -219,119 +219,115 @@ private fun CourseHomeUI( ) { when (uiState) { is CourseHomeUIState.CourseData -> { - if (uiState.courseStructure.blockData.isEmpty()) { - NoContentScreen(noContentScreenType = NoContentScreenType.COURSE_OUTLINE) - } else { - Column( - modifier = Modifier - .fillMaxSize() - .verticalScroll(rememberScrollState()), - ) { - if (uiState.datesBannerInfo.isBannerAvailableForDashboard()) { - Box( - modifier = Modifier - .padding(all = 8.dp) - ) { - if (windowSize.isTablet) { - CourseDatesBannerTablet( - banner = uiState.datesBannerInfo, - resetDates = onResetDatesClick, - ) - } else { - CourseDatesBanner( - banner = uiState.datesBannerInfo, - resetDates = onResetDatesClick, - ) - } + Column( + modifier = Modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()), + ) { + if (uiState.datesBannerInfo.isBannerAvailableForDashboard()) { + Box( + modifier = Modifier + .padding(all = 8.dp) + ) { + if (windowSize.isTablet) { + CourseDatesBannerTablet( + banner = uiState.datesBannerInfo, + resetDates = onResetDatesClick, + ) + } else { + CourseDatesBanner( + banner = uiState.datesBannerInfo, + resetDates = onResetDatesClick, + ) } } + } - val certificate = uiState.courseStructure.certificate - if (certificate?.isCertificateEarned() == true) { - CourseMessage( - modifier = Modifier - .fillMaxWidth() - .padding( - vertical = 12.dp, - horizontal = 24.dp - ), - icon = painterResource(R.drawable.course_ic_certificate), - message = stringResource( - R.string.course_you_earned_certificate, - uiState.courseStructure.name + val certificate = uiState.courseStructure.certificate + if (certificate?.isCertificateEarned() == true) { + CourseMessage( + modifier = Modifier + .fillMaxWidth() + .padding( + vertical = 12.dp, + horizontal = 24.dp ), - action = stringResource(R.string.course_view_certificate), - onActionClick = { - onCertificateClick( - certificate.certificateURL ?: "" - ) - } - ) - } + icon = painterResource(R.drawable.course_ic_certificate), + message = stringResource( + R.string.course_you_earned_certificate, + uiState.courseStructure.name + ), + action = stringResource(R.string.course_view_certificate), + onActionClick = { + onCertificateClick( + certificate.certificateURL ?: "" + ) + } + ) + } - if (uiState.resumeComponent != null) { - ResumeCourseButton( - modifier = Modifier.padding(16.dp), - block = uiState.resumeComponent, - displayName = uiState.resumeUnitTitle, - onResumeClick = onResumeClick - ) - } + if (uiState.resumeComponent != null) { + ResumeCourseButton( + modifier = Modifier.padding(16.dp), + block = uiState.resumeComponent, + displayName = uiState.resumeUnitTitle, + onResumeClick = onResumeClick + ) + } - Spacer(modifier = Modifier.height(12.dp)) - CourseHomePager( - modifier = Modifier.fillMaxSize(), - pages = CourseHomePagerTab.entries, - pagerState = homePagerState - ) { tab -> - Card( - modifier = Modifier.fillMaxWidth(), - backgroundColor = MaterialTheme.appColors.cardViewBackground, - border = BorderStroke( - 1.dp, - MaterialTheme.appColors.cardViewBorder - ), - shape = MaterialTheme.appShapes.cardShape, - elevation = 0.dp, - ) { - when (tab) { - CourseHomePagerTab.COURSE_COMPLETION -> { - CourseCompletionHomePagerCardContent( - uiState = uiState, - onViewAllContentClick = { - onNavigateToContent(CourseContentTab.ALL) - }, - onDownloadClick = onDownloadClick, - onSubSectionClick = onSubSectionClick - ) - } + Spacer(modifier = Modifier.height(12.dp)) + CourseHomePager( + modifier = Modifier.fillMaxSize(), + pages = CourseHomePagerTab.entries, + pagerState = homePagerState + ) { tab -> + Card( + modifier = Modifier.fillMaxWidth(), + backgroundColor = MaterialTheme.appColors.cardViewBackground, + border = BorderStroke( + 1.dp, + MaterialTheme.appColors.cardViewBorder + ), + shape = MaterialTheme.appShapes.cardShape, + elevation = 0.dp, + ) { + when (tab) { + CourseHomePagerTab.COURSE_COMPLETION -> { + CourseCompletionHomePagerCardContent( + uiState = uiState, + onViewAllContentClick = { + onNavigateToContent(CourseContentTab.ALL) + }, + onDownloadClick = onDownloadClick, + onSubSectionClick = onSubSectionClick + ) + } - CourseHomePagerTab.VIDEOS -> { - VideosHomePagerCardContent( - uiState = uiState, - onVideoClick = onVideoClick, - onViewAllVideosClick = { - onNavigateToContent(CourseContentTab.VIDEOS) - } - ) - } + CourseHomePagerTab.VIDEOS -> { + VideosHomePagerCardContent( + uiState = uiState, + onVideoClick = onVideoClick, + onViewAllVideosClick = { + onNavigateToContent(CourseContentTab.VIDEOS) + } + ) + } - CourseHomePagerTab.ASSIGNMENT -> { - AssignmentsHomePagerCardContent( - uiState = uiState, - onAssignmentClick = onAssignmentClick, - onViewAllAssignmentsClick = { - onNavigateToContent(CourseContentTab.ASSIGNMENTS) - } - ) - } + CourseHomePagerTab.ASSIGNMENT -> { + AssignmentsHomePagerCardContent( + uiState = uiState, + onAssignmentClick = onAssignmentClick, + onViewAllAssignmentsClick = { + onNavigateToContent(CourseContentTab.ASSIGNMENTS) + } + ) + } - CourseHomePagerTab.GRADES -> { - GradesHomePagerCardContent( - uiState = uiState, - onViewProgressClick = onNavigateToProgress - ) - } + CourseHomePagerTab.GRADES -> { + GradesHomePagerCardContent( + uiState = uiState, + onViewProgressClick = onNavigateToProgress + ) } } } diff --git a/course/src/main/java/org/openedx/course/presentation/home/GradesHomePagerCardContent.kt b/course/src/main/java/org/openedx/course/presentation/home/GradesHomePagerCardContent.kt index 80b1ac714..967fb3c41 100644 --- a/course/src/main/java/org/openedx/course/presentation/home/GradesHomePagerCardContent.kt +++ b/course/src/main/java/org/openedx/course/presentation/home/GradesHomePagerCardContent.kt @@ -29,6 +29,7 @@ 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.course.presentation.contenttab.CourseHomeGradesEmptyState import org.openedx.course.presentation.progress.CurrentOverallGradeText import org.openedx.course.presentation.progress.GradeProgressBar import org.openedx.course.presentation.progress.RequiredGradeMarker @@ -41,6 +42,11 @@ fun GradesHomePagerCardContent( val courseProgress = uiState.courseProgress val gradingPolicy = courseProgress?.gradingPolicy + if (courseProgress == null || gradingPolicy == null || gradingPolicy.assignmentPolicies.isEmpty()) { + CourseHomeGradesEmptyState() + return + } + Column( modifier = Modifier .fillMaxWidth() @@ -59,33 +65,25 @@ fun GradesHomePagerCardContent( color = MaterialTheme.appColors.textPrimaryVariant, ) Spacer(modifier = Modifier.height(16.dp)) - if (courseProgress != null && gradingPolicy != null) { - CurrentOverallGradeText(progress = courseProgress) - Spacer(modifier = Modifier.height(12.dp)) - GradeProgressBar( - progress = courseProgress, - gradingPolicy = gradingPolicy, - notCompletedWeightedGradePercent = courseProgress.getNotCompletedWeightedGradePercent() - ) - RequiredGradeMarker(progress = courseProgress) - Spacer(modifier = Modifier.height(20.dp)) - GradeCardsGrid( - assignmentPolicies = gradingPolicy.assignmentPolicies, - assignmentColors = gradingPolicy.assignmentColors, - progress = courseProgress - ) - Spacer(modifier = Modifier.height(8.dp)) - ViewAllButton( - text = stringResource(R.string.course_view_progress), - onClick = onViewProgressClick, - ) - } else { - Text( - text = stringResource(R.string.course_progress_no_assignments), - style = MaterialTheme.appTypography.labelMedium, - color = MaterialTheme.appColors.textPrimaryVariant, - ) - } + CurrentOverallGradeText(progress = courseProgress) + Spacer(modifier = Modifier.height(12.dp)) + GradeProgressBar( + progress = courseProgress, + gradingPolicy = gradingPolicy, + notCompletedWeightedGradePercent = courseProgress.getNotCompletedWeightedGradePercent() + ) + RequiredGradeMarker(progress = courseProgress) + Spacer(modifier = Modifier.height(20.dp)) + GradeCardsGrid( + assignmentPolicies = gradingPolicy.assignmentPolicies, + assignmentColors = gradingPolicy.assignmentColors, + progress = courseProgress + ) + Spacer(modifier = Modifier.height(8.dp)) + ViewAllButton( + text = stringResource(R.string.course_view_progress), + onClick = onViewProgressClick, + ) } } diff --git a/course/src/main/java/org/openedx/course/presentation/home/VideosHomePagerCardContent.kt b/course/src/main/java/org/openedx/course/presentation/home/VideosHomePagerCardContent.kt index 93d42d058..8d66866ec 100644 --- a/course/src/main/java/org/openedx/course/presentation/home/VideosHomePagerCardContent.kt +++ b/course/src/main/java/org/openedx/course/presentation/home/VideosHomePagerCardContent.kt @@ -27,6 +27,7 @@ import org.openedx.core.domain.model.Block import org.openedx.core.ui.theme.appColors import org.openedx.core.ui.theme.appTypography import org.openedx.course.R +import org.openedx.course.presentation.contenttab.CourseContentVideoEmptyState import org.openedx.course.presentation.ui.CourseVideoItem @Composable @@ -36,6 +37,15 @@ fun VideosHomePagerCardContent( onViewAllVideosClick: () -> Unit ) { val allVideos = uiState.courseVideos.values.flatten() + + if (allVideos.isEmpty()) { + CourseContentVideoEmptyState( + onReturnToCourseClick = {}, + showReturnButton = false + ) + return + } + val completedVideos = allVideos.count { it.isCompleted() } val totalVideos = allVideos.size val firstIncompleteVideo = allVideos.find { !it.isCompleted() } diff --git a/course/src/main/java/org/openedx/course/presentation/outline/CourseContentAllScreen.kt b/course/src/main/java/org/openedx/course/presentation/outline/CourseContentAllScreen.kt index 44647e3be..4ccffd99e 100644 --- a/course/src/main/java/org/openedx/course/presentation/outline/CourseContentAllScreen.kt +++ b/course/src/main/java/org/openedx/course/presentation/outline/CourseContentAllScreen.kt @@ -11,6 +11,8 @@ import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll import androidx.compose.material.MaterialTheme import androidx.compose.material.Scaffold import androidx.compose.material.Surface @@ -323,6 +325,7 @@ private fun CourseContentAllUI( CourseContentAllUIState.Error -> { CourseContentAllEmptyState( + modifier = Modifier.verticalScroll(rememberScrollState()), onReturnToCourseClick = onNavigateToHome ) } diff --git a/course/src/main/java/org/openedx/course/presentation/videos/CourseContentVideoScreen.kt b/course/src/main/java/org/openedx/course/presentation/videos/CourseContentVideoScreen.kt index 571fde683..6872b49c5 100644 --- a/course/src/main/java/org/openedx/course/presentation/videos/CourseContentVideoScreen.kt +++ b/course/src/main/java/org/openedx/course/presentation/videos/CourseContentVideoScreen.kt @@ -9,6 +9,8 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll import androidx.compose.material.Divider import androidx.compose.material.MaterialTheme import androidx.compose.material.Scaffold @@ -142,6 +144,7 @@ private fun CourseVideosUI( when (uiState) { is CourseVideoUIState.Empty -> { CourseContentVideoEmptyState( + modifier = Modifier.verticalScroll(rememberScrollState()), onReturnToCourseClick = onNavigateToHome ) } From 11657ff372b2188e789157472ef7437e2438c828 Mon Sep 17 00:00:00 2001 From: PavloNetrebchuk Date: Thu, 4 Sep 2025 16:29:50 +0300 Subject: [PATCH 08/17] feat: CaughtUpMessage --- .../home/AssignmentsHomePagerCardContent.kt | 4 ++ .../presentation/home/CourseHomeScreen.kt | 66 ++++++++++++------- .../home/VideosHomePagerCardContent.kt | 4 ++ course/src/main/res/values/strings.xml | 4 ++ 4 files changed, 53 insertions(+), 25 deletions(-) diff --git a/course/src/main/java/org/openedx/course/presentation/home/AssignmentsHomePagerCardContent.kt b/course/src/main/java/org/openedx/course/presentation/home/AssignmentsHomePagerCardContent.kt index 431e65d42..709b56e58 100644 --- a/course/src/main/java/org/openedx/course/presentation/home/AssignmentsHomePagerCardContent.kt +++ b/course/src/main/java/org/openedx/course/presentation/home/AssignmentsHomePagerCardContent.kt @@ -117,6 +117,10 @@ fun AssignmentsHomePagerCardContent( assignment = firstIncompleteAssignment, onAssignmentClick = onAssignmentClick ) + } else { + CaughtUpMessage( + message = stringResource(R.string.course_assignments_caught_up) + ) } Spacer(modifier = Modifier.height(8.dp)) diff --git a/course/src/main/java/org/openedx/course/presentation/home/CourseHomeScreen.kt b/course/src/main/java/org/openedx/course/presentation/home/CourseHomeScreen.kt index f84bfd332..bcd702985 100644 --- a/course/src/main/java/org/openedx/course/presentation/home/CourseHomeScreen.kt +++ b/course/src/main/java/org/openedx/course/presentation/home/CourseHomeScreen.kt @@ -41,6 +41,7 @@ import androidx.compose.ui.platform.AndroidUriHandler import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.tooling.preview.Devices import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.Dp @@ -367,6 +368,46 @@ fun CourseHomePager( } } +@Composable +fun ViewAllButton( + text: String, + onClick: () -> Unit, + modifier: Modifier = Modifier, +) { + TextButton( + onClick = onClick, + modifier = modifier.fillMaxWidth() + ) { + Icon( + imageVector = Icons.AutoMirrored.Filled.List, + contentDescription = null, + tint = MaterialTheme.appColors.primary, + modifier = Modifier.size(20.dp) + ) + Spacer(modifier = Modifier.width(8.dp)) + Text( + text = text, + style = MaterialTheme.appTypography.labelLarge, + color = MaterialTheme.appColors.primary + ) + } +} + +@Composable +fun CaughtUpMessage( + modifier: Modifier = Modifier, + message: String, +) { + Text( + modifier = modifier + .fillMaxWidth(), + text = message, + color = MaterialTheme.appColors.textPrimary, + style = MaterialTheme.appTypography.bodyLarge, + textAlign = TextAlign.Center + ) +} + @Preview(uiMode = UI_MODE_NIGHT_NO) @Preview(uiMode = UI_MODE_NIGHT_YES) @Composable @@ -462,28 +503,3 @@ private fun CourseHomeScreenTabletPreview() { ) } } - -@Composable -fun ViewAllButton( - text: String, - onClick: () -> Unit, - modifier: Modifier = Modifier, -) { - TextButton( - onClick = onClick, - modifier = modifier.fillMaxWidth() - ) { - Icon( - imageVector = Icons.AutoMirrored.Filled.List, - contentDescription = null, - tint = MaterialTheme.appColors.primary, - modifier = Modifier.size(20.dp) - ) - Spacer(modifier = Modifier.width(8.dp)) - Text( - text = text, - style = MaterialTheme.appTypography.labelLarge, - color = MaterialTheme.appColors.primary - ) - } -} diff --git a/course/src/main/java/org/openedx/course/presentation/home/VideosHomePagerCardContent.kt b/course/src/main/java/org/openedx/course/presentation/home/VideosHomePagerCardContent.kt index 8d66866ec..7d6d198e3 100644 --- a/course/src/main/java/org/openedx/course/presentation/home/VideosHomePagerCardContent.kt +++ b/course/src/main/java/org/openedx/course/presentation/home/VideosHomePagerCardContent.kt @@ -132,6 +132,10 @@ fun VideosHomePagerCardContent( } ) } + } else { + CaughtUpMessage( + message = stringResource(R.string.course_videos_caught_up) + ) } Spacer(modifier = Modifier.height(8.dp)) diff --git a/course/src/main/res/values/strings.xml b/course/src/main/res/values/strings.xml index 3082b880a..3bdc0839e 100644 --- a/course/src/main/res/values/strings.xml +++ b/course/src/main/res/values/strings.xml @@ -109,4 +109,8 @@ Grades This represents your weighted grade against the grade needed to pass this course. View Progress + + + You\'re all caught up. Take a breather and relax. + You\'re all caught up on assignments. Take a breather and relax. From c9f3cbe5298b3ac56694897d9bc1615de6f62e9e Mon Sep 17 00:00:00 2001 From: PavloNetrebchuk Date: Thu, 4 Sep 2025 16:47:52 +0300 Subject: [PATCH 09/17] feat: A11y --- .../presentation/home/AssignmentsHomePagerCardContent.kt | 5 ++++- .../home/CourseCompletionHomePagerCardContent.kt | 5 ++++- .../course/presentation/home/GradesHomePagerCardContent.kt | 7 +++++-- .../course/presentation/home/VideosHomePagerCardContent.kt | 5 ++++- 4 files changed, 17 insertions(+), 5 deletions(-) diff --git a/course/src/main/java/org/openedx/course/presentation/home/AssignmentsHomePagerCardContent.kt b/course/src/main/java/org/openedx/course/presentation/home/AssignmentsHomePagerCardContent.kt index 709b56e58..a17c616fd 100644 --- a/course/src/main/java/org/openedx/course/presentation/home/AssignmentsHomePagerCardContent.kt +++ b/course/src/main/java/org/openedx/course/presentation/home/AssignmentsHomePagerCardContent.kt @@ -26,6 +26,7 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.semantics import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import org.openedx.core.domain.model.Block @@ -71,7 +72,9 @@ fun AssignmentsHomePagerCardContent( // Progress section Row( - modifier = Modifier.fillMaxWidth(), + modifier = Modifier + .fillMaxWidth() + .semantics(mergeDescendants = true) {}, verticalAlignment = Alignment.CenterVertically ) { Icon( diff --git a/course/src/main/java/org/openedx/course/presentation/home/CourseCompletionHomePagerCardContent.kt b/course/src/main/java/org/openedx/course/presentation/home/CourseCompletionHomePagerCardContent.kt index a655b1f68..5af9a765f 100644 --- a/course/src/main/java/org/openedx/course/presentation/home/CourseCompletionHomePagerCardContent.kt +++ b/course/src/main/java/org/openedx/course/presentation/home/CourseCompletionHomePagerCardContent.kt @@ -13,6 +13,7 @@ import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.semantics import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp @@ -58,7 +59,9 @@ fun CourseCompletionHomePagerCardContent( verticalAlignment = Alignment.CenterVertically ) { Column( - modifier = Modifier.weight(1f), + modifier = Modifier + .weight(1f) + .semantics(mergeDescendants = true) {}, verticalArrangement = Arrangement.spacedBy(8.dp) ) { Text( diff --git a/course/src/main/java/org/openedx/course/presentation/home/GradesHomePagerCardContent.kt b/course/src/main/java/org/openedx/course/presentation/home/GradesHomePagerCardContent.kt index 967fb3c41..d18495ecf 100644 --- a/course/src/main/java/org/openedx/course/presentation/home/GradesHomePagerCardContent.kt +++ b/course/src/main/java/org/openedx/course/presentation/home/GradesHomePagerCardContent.kt @@ -21,6 +21,7 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.semantics import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp @@ -99,7 +100,9 @@ private fun GradeCard( val gradePercent = if (possible > 0) (earned.toFloat() / possible * 100).toInt() else 0 Card( - modifier = modifier.fillMaxWidth(), + modifier = modifier + .fillMaxWidth() + .semantics(mergeDescendants = true) {}, backgroundColor = color.copy(alpha = 0.1f), shape = MaterialTheme.appShapes.material.small, elevation = 0.dp, @@ -179,6 +182,7 @@ private fun GradeCardsGrid( rowPolicies.forEachIndexed { index, policy -> val policyIndex = assignmentPolicies.indexOf(policy) GradeCard( + modifier = Modifier.weight(1f), policy = policy, progress = progress, color = if (assignmentColors.isNotEmpty()) { @@ -186,7 +190,6 @@ private fun GradeCardsGrid( } else { MaterialTheme.appColors.primary }, - modifier = Modifier.weight(1f) ) } // Fill remaining space if row has only 1 item diff --git a/course/src/main/java/org/openedx/course/presentation/home/VideosHomePagerCardContent.kt b/course/src/main/java/org/openedx/course/presentation/home/VideosHomePagerCardContent.kt index 7d6d198e3..9fd95769d 100644 --- a/course/src/main/java/org/openedx/course/presentation/home/VideosHomePagerCardContent.kt +++ b/course/src/main/java/org/openedx/course/presentation/home/VideosHomePagerCardContent.kt @@ -21,6 +21,7 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.semantics import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import org.openedx.core.domain.model.Block @@ -64,7 +65,9 @@ fun VideosHomePagerCardContent( ) Spacer(modifier = Modifier.height(12.dp)) Row( - modifier = Modifier.fillMaxWidth(), + modifier = Modifier + .fillMaxWidth() + .semantics(mergeDescendants = true) {}, verticalAlignment = Alignment.CenterVertically ) { Icon( From 36e49acd6710b83d1932cfc0d6caac69f9b82257 Mon Sep 17 00:00:00 2001 From: PavloNetrebchuk Date: Thu, 4 Sep 2025 18:01:52 +0300 Subject: [PATCH 10/17] feat: detekt fixes --- .../container/CourseContainerFragment.kt | 4 +++- .../home/AssignmentsHomePagerCardContent.kt | 11 +++++++++-- .../CourseCompletionHomePagerCardContent.kt | 4 +++- .../presentation/home/CourseHomePagerTab.kt | 2 +- .../presentation/home/CourseHomeScreen.kt | 2 ++ .../presentation/home/CourseHomeUIState.kt | 1 + .../presentation/home/CourseHomeViewModel.kt | 19 +++++++++++++------ .../home/GradesHomePagerCardContent.kt | 1 - .../home/VideosHomePagerCardContent.kt | 1 - .../course/presentation/ui/CourseUI.kt | 4 ---- 10 files changed, 32 insertions(+), 17 deletions(-) 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 f5c8a66ff..89212a06d 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 @@ -307,7 +307,9 @@ fun CourseDashboard( bottomBar = { val currentPage = CourseContainerTab.entries[pagerState.currentPage] Box { - if (currentPage == CourseContainerTab.CONTENT && selectedContentTab == CourseContentTab.ASSIGNMENTS) { + if (currentPage == CourseContainerTab.CONTENT && + selectedContentTab == CourseContentTab.ASSIGNMENTS + ) { AssignmentsBottomBar(scope = scope, pagerState = pagerState) } else if (currentPage == CourseContainerTab.HOME) { HomeNavigationRow(homePagerState = homePagerState) diff --git a/course/src/main/java/org/openedx/course/presentation/home/AssignmentsHomePagerCardContent.kt b/course/src/main/java/org/openedx/course/presentation/home/AssignmentsHomePagerCardContent.kt index a17c616fd..088dfc11d 100644 --- a/course/src/main/java/org/openedx/course/presentation/home/AssignmentsHomePagerCardContent.kt +++ b/course/src/main/java/org/openedx/course/presentation/home/AssignmentsHomePagerCardContent.kt @@ -38,6 +38,14 @@ import org.openedx.course.presentation.contenttab.CourseContentAssignmentEmptySt import java.util.Date import org.openedx.core.R as coreR +private const val MILLISECONDS_PER_SECOND = 1000 +private const val SECONDS_PER_MINUTE = 60 +private const val MINUTES_PER_HOUR = 60 +private const val HOURS_PER_DAY = 24 + +private const val MILLISECONDS_PER_DAY = + MILLISECONDS_PER_SECOND * SECONDS_PER_MINUTE * MINUTES_PER_HOUR * HOURS_PER_DAY + @Composable fun AssignmentsHomePagerCardContent( uiState: CourseHomeUIState.CourseData, @@ -153,7 +161,7 @@ private fun AssignmentCard( // Due date status text val dueDateStatusText = assignment.due?.let { due -> val formattedDate = TimeUtils.formatToDayMonth(due) - val daysDifference = ((due.time - Date().time) / (1000 * 60 * 60 * 24)).toInt() + val daysDifference = ((due.time - Date().time) / MILLISECONDS_PER_DAY).toInt() when { daysDifference < 0 -> { // Past due @@ -237,7 +245,6 @@ private fun AssignmentCard( Spacer(modifier = Modifier.height(4.dp)) } - // Assignment and section name Text( text = assignment.assignmentProgress?.assignmentType ?: "", diff --git a/course/src/main/java/org/openedx/course/presentation/home/CourseCompletionHomePagerCardContent.kt b/course/src/main/java/org/openedx/course/presentation/home/CourseCompletionHomePagerCardContent.kt index 5af9a765f..d1cd730be 100644 --- a/course/src/main/java/org/openedx/course/presentation/home/CourseCompletionHomePagerCardContent.kt +++ b/course/src/main/java/org/openedx/course/presentation/home/CourseCompletionHomePagerCardContent.kt @@ -99,7 +99,9 @@ fun CourseCompletionHomePagerCardContent( CourseSection( section = chapter, - onItemClick = {}, + onItemClick = { + onSubSectionClick(subsection) + }, isExpandable = false, isSectionVisible = true, useRelativeDates = uiState.useRelativeDates, diff --git a/course/src/main/java/org/openedx/course/presentation/home/CourseHomePagerTab.kt b/course/src/main/java/org/openedx/course/presentation/home/CourseHomePagerTab.kt index 668bf319d..d18dad224 100644 --- a/course/src/main/java/org/openedx/course/presentation/home/CourseHomePagerTab.kt +++ b/course/src/main/java/org/openedx/course/presentation/home/CourseHomePagerTab.kt @@ -5,4 +5,4 @@ enum class CourseHomePagerTab { VIDEOS, ASSIGNMENT, GRADES -} \ No newline at end of file +} diff --git a/course/src/main/java/org/openedx/course/presentation/home/CourseHomeScreen.kt b/course/src/main/java/org/openedx/course/presentation/home/CourseHomeScreen.kt index bcd702985..baa5cd5f3 100644 --- a/course/src/main/java/org/openedx/course/presentation/home/CourseHomeScreen.kt +++ b/course/src/main/java/org/openedx/course/presentation/home/CourseHomeScreen.kt @@ -343,6 +343,8 @@ private fun CourseHomeUI( CourseHomeUIState.Loading -> { CircularProgress() } + + CourseHomeUIState.Waiting -> {} } } } diff --git a/course/src/main/java/org/openedx/course/presentation/home/CourseHomeUIState.kt b/course/src/main/java/org/openedx/course/presentation/home/CourseHomeUIState.kt index cd2d6f83a..886db4a7b 100644 --- a/course/src/main/java/org/openedx/course/presentation/home/CourseHomeUIState.kt +++ b/course/src/main/java/org/openedx/course/presentation/home/CourseHomeUIState.kt @@ -27,4 +27,5 @@ sealed class CourseHomeUIState { data object Error : CourseHomeUIState() data object Loading : CourseHomeUIState() + data object Waiting : CourseHomeUIState() } diff --git a/course/src/main/java/org/openedx/course/presentation/home/CourseHomeViewModel.kt b/course/src/main/java/org/openedx/course/presentation/home/CourseHomeViewModel.kt index 281e26780..12f0f27fc 100644 --- a/course/src/main/java/org/openedx/course/presentation/home/CourseHomeViewModel.kt +++ b/course/src/main/java/org/openedx/course/presentation/home/CourseHomeViewModel.kt @@ -76,7 +76,7 @@ class CourseHomeViewModel( ) { val isCourseDropdownNavigationEnabled get() = config.getCourseUIConfig().isCourseDropdownNavigationEnabled - private val _uiState = MutableStateFlow(CourseHomeUIState.Loading) + private val _uiState = MutableStateFlow(CourseHomeUIState.Waiting) val uiState: StateFlow get() = _uiState.asStateFlow() @@ -337,11 +337,15 @@ class CourseHomeViewModel( * where the first Block is the chapter and the second Block is the first incomplete subsection */ private fun findFirstChapterWithIncompleteDescendants(blocks: List): Pair? { - val incompleteChapterBlock = - blocks.getChapterBlocks().find { !it.isCompleted() } ?: return null - val incompleteSubsection = - findFirstIncompleteSubsection(incompleteChapterBlock, blocks) ?: return null - return Pair(incompleteChapterBlock, incompleteSubsection) + val incompleteChapterBlock = blocks.getChapterBlocks().find { !it.isCompleted() } + val incompleteSubsection = incompleteChapterBlock?.let { + findFirstIncompleteSubsection(it, blocks) + } + return if (incompleteChapterBlock != null && incompleteSubsection != null) { + Pair(incompleteChapterBlock, incompleteSubsection) + } else { + null + } } private fun findFirstIncompleteSubsection(chapter: Block, blocks: List): Block? { @@ -534,6 +538,9 @@ class CourseHomeViewModel( fun getCourseProgress() { viewModelScope.launch { + if (_uiState.value !is CourseHomeUIState.CourseData) { + _uiState.value = CourseHomeUIState.Loading + } interactor.getCourseProgress(courseId, false) .catch { e -> if (_uiState.value !is CourseHomeUIState.CourseData) { diff --git a/course/src/main/java/org/openedx/course/presentation/home/GradesHomePagerCardContent.kt b/course/src/main/java/org/openedx/course/presentation/home/GradesHomePagerCardContent.kt index d18495ecf..feddb43e7 100644 --- a/course/src/main/java/org/openedx/course/presentation/home/GradesHomePagerCardContent.kt +++ b/course/src/main/java/org/openedx/course/presentation/home/GradesHomePagerCardContent.kt @@ -200,4 +200,3 @@ private fun GradeCardsGrid( } } } - diff --git a/course/src/main/java/org/openedx/course/presentation/home/VideosHomePagerCardContent.kt b/course/src/main/java/org/openedx/course/presentation/home/VideosHomePagerCardContent.kt index 9fd95769d..57a916d42 100644 --- a/course/src/main/java/org/openedx/course/presentation/home/VideosHomePagerCardContent.kt +++ b/course/src/main/java/org/openedx/course/presentation/home/VideosHomePagerCardContent.kt @@ -38,7 +38,6 @@ fun VideosHomePagerCardContent( onViewAllVideosClick: () -> Unit ) { val allVideos = uiState.courseVideos.values.flatten() - if (allVideos.isEmpty()) { CourseContentVideoEmptyState( onReturnToCourseClick = {}, diff --git a/course/src/main/java/org/openedx/course/presentation/ui/CourseUI.kt b/course/src/main/java/org/openedx/course/presentation/ui/CourseUI.kt index 74a5ad0ea..3a27f17ed 100644 --- a/course/src/main/java/org/openedx/course/presentation/ui/CourseUI.kt +++ b/course/src/main/java/org/openedx/course/presentation/ui/CourseUI.kt @@ -1755,7 +1755,3 @@ private val mockChapterBlock = Block( due = Date(), offlineDownload = null ) - - - - From 05fc28067d923870c22d2089080433ff453ace4f Mon Sep 17 00:00:00 2001 From: PavloNetrebchuk Date: Fri, 5 Sep 2025 15:18:40 +0300 Subject: [PATCH 11/17] feat: changes according demo feedback --- .../java/org/openedx/core/domain/model/CourseProgress.kt | 8 ++++++++ .../presentation/home/AssignmentsHomePagerCardContent.kt | 9 ++++++--- .../openedx/course/presentation/home/CourseHomeScreen.kt | 9 +++++++-- .../presentation/home/GradesHomePagerCardContent.kt | 3 ++- .../course/presentation/progress/CourseProgressScreen.kt | 5 +++-- 5 files changed, 26 insertions(+), 8 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 537959ece..f4d1639cd 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 @@ -68,6 +68,14 @@ data class CourseProgress( return if (notCompletedPercent < 0.0) 0f else notCompletedPercent.toFloat() } + fun getNotEmptyGradingPolicies() = gradingPolicy?.assignmentPolicies?.mapNotNull { + if (getPossibleAssignmentProblems(it) > 0) { + it + } else { + null + } + } + data class CertificateData( val certStatus: String, val certWebViewUrl: String, diff --git a/course/src/main/java/org/openedx/course/presentation/home/AssignmentsHomePagerCardContent.kt b/course/src/main/java/org/openedx/course/presentation/home/AssignmentsHomePagerCardContent.kt index 088dfc11d..b5ffcb8dd 100644 --- a/course/src/main/java/org/openedx/course/presentation/home/AssignmentsHomePagerCardContent.kt +++ b/course/src/main/java/org/openedx/course/presentation/home/AssignmentsHomePagerCardContent.kt @@ -50,7 +50,8 @@ private const val MILLISECONDS_PER_DAY = fun AssignmentsHomePagerCardContent( uiState: CourseHomeUIState.CourseData, onAssignmentClick: (Block) -> Unit, - onViewAllAssignmentsClick: () -> Unit + onViewAllAssignmentsClick: () -> Unit, + getBlockParent: (blockId: String) -> Block?, ) { if (uiState.courseAssignments.isEmpty()) { CourseContentAssignmentEmptyState( @@ -126,6 +127,7 @@ fun AssignmentsHomePagerCardContent( if (firstIncompleteAssignment != null) { AssignmentCard( assignment = firstIncompleteAssignment, + sectionName = getBlockParent(firstIncompleteAssignment.id)?.displayName ?: "", onAssignmentClick = onAssignmentClick ) } else { @@ -147,6 +149,7 @@ fun AssignmentsHomePagerCardContent( @Composable private fun AssignmentCard( assignment: Block, + sectionName: String, onAssignmentClick: (Block) -> Unit ) { val isDuePast = assignment.due != null && assignment.due!! < Date() @@ -247,14 +250,14 @@ private fun AssignmentCard( // Assignment and section name Text( - text = assignment.assignmentProgress?.assignmentType ?: "", + text = assignment.displayName, style = MaterialTheme.appTypography.titleSmall, color = MaterialTheme.appColors.textPrimary, fontWeight = FontWeight.Bold ) Spacer(modifier = Modifier.height(4.dp)) Text( - text = assignment.displayName, + text = sectionName, style = MaterialTheme.appTypography.labelSmall, color = MaterialTheme.appColors.textPrimaryVariant, ) diff --git a/course/src/main/java/org/openedx/course/presentation/home/CourseHomeScreen.kt b/course/src/main/java/org/openedx/course/presentation/home/CourseHomeScreen.kt index baa5cd5f3..3baec6026 100644 --- a/course/src/main/java/org/openedx/course/presentation/home/CourseHomeScreen.kt +++ b/course/src/main/java/org/openedx/course/presentation/home/CourseHomeScreen.kt @@ -168,7 +168,8 @@ fun CourseHomeScreen( viewModel.logAssignmentClick(assignmentBlock.id) }, onNavigateToContent = onNavigateToContent, - onNavigateToProgress = onNavigateToProgress + onNavigateToProgress = onNavigateToProgress, + getBlockParent = viewModel::getBlockParent ) } @@ -187,6 +188,7 @@ private fun CourseHomeUI( onAssignmentClick: (Block) -> Unit, onNavigateToContent: (CourseContentTab) -> Unit, onNavigateToProgress: () -> Unit, + getBlockParent: (blockId: String) -> Block?, ) { val scaffoldState = rememberScaffoldState() @@ -318,6 +320,7 @@ private fun CourseHomeUI( AssignmentsHomePagerCardContent( uiState = uiState, onAssignmentClick = onAssignmentClick, + getBlockParent = getBlockParent, onViewAllAssignmentsClick = { onNavigateToContent(CourseContentTab.ASSIGNMENTS) } @@ -453,7 +456,8 @@ private fun CourseHomeScreenPreview() { onVideoClick = {}, onAssignmentClick = {}, onNavigateToContent = { _ -> }, - onNavigateToProgress = {} + onNavigateToProgress = {}, + getBlockParent = { null }, ) } } @@ -502,6 +506,7 @@ private fun CourseHomeScreenTabletPreview() { onAssignmentClick = {}, onNavigateToContent = { _ -> }, onNavigateToProgress = { }, + getBlockParent = { null }, ) } } diff --git a/course/src/main/java/org/openedx/course/presentation/home/GradesHomePagerCardContent.kt b/course/src/main/java/org/openedx/course/presentation/home/GradesHomePagerCardContent.kt index feddb43e7..dab39ef31 100644 --- a/course/src/main/java/org/openedx/course/presentation/home/GradesHomePagerCardContent.kt +++ b/course/src/main/java/org/openedx/course/presentation/home/GradesHomePagerCardContent.kt @@ -42,8 +42,9 @@ fun GradesHomePagerCardContent( ) { val courseProgress = uiState.courseProgress val gradingPolicy = courseProgress?.gradingPolicy + val assignmentPolicies = courseProgress?.getNotEmptyGradingPolicies() - if (courseProgress == null || gradingPolicy == null || gradingPolicy.assignmentPolicies.isEmpty()) { + if (courseProgress == null || gradingPolicy == null || assignmentPolicies.isNullOrEmpty()) { CourseHomeGradesEmptyState() return } 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 bb04c3d90..613511252 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 @@ -134,7 +134,8 @@ private fun CourseProgressContent( ) } if (gradingPolicy == null) return@LazyColumn - if (gradingPolicy.assignmentPolicies.isNotEmpty()) { + val assignmentPolicies = uiState.progress.getNotEmptyGradingPolicies() + if (!assignmentPolicies.isNullOrEmpty()) { item { OverallGradeView( progress = uiState.progress, @@ -143,7 +144,7 @@ private fun CourseProgressContent( item { GradeDetailsHeaderView() } - itemsIndexed(gradingPolicy.assignmentPolicies) { index, policy -> + itemsIndexed(assignmentPolicies) { index, policy -> AssignmentTypeRow( progress = uiState.progress, policy = policy, From 42eda198ad5991521cd590dc983eb81f51e9a6fb Mon Sep 17 00:00:00 2001 From: PavloNetrebchuk Date: Fri, 5 Sep 2025 17:09:38 +0300 Subject: [PATCH 12/17] feat: course home analytic --- .../course/presentation/CourseAnalytics.kt | 28 +++ .../presentation/home/CourseHomeScreen.kt | 39 +++- .../presentation/home/CourseHomeViewModel.kt | 181 ++++++++++++------ 3 files changed, 178 insertions(+), 70 deletions(-) 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 99ff6d2e1..68468c654 100644 --- a/course/src/main/java/org/openedx/course/presentation/CourseAnalytics.kt +++ b/course/src/main/java/org/openedx/course/presentation/CourseAnalytics.kt @@ -178,6 +178,34 @@ enum class CourseAnalyticsEvent(val eventName: String, val biValue: String) { "Course:Assignment Tab.Assignment Clicked", "edx.bi.app.course.assignment_tab.assignment.clicked" ), + COURSE_HOME_SECTION_SUBSECTION_CLICK( + "Course Home.Section/Subsection Click", + "edx.bi.app.course.home.section_subsection.clicked" + ), + COURSE_HOME_VIEW_ALL_CONTENT( + "Course Home.View All Content", + "edx.bi.app.course.home.view_all_content.clicked" + ), + COURSE_HOME_VIEW_ALL_VIDEOS( + "Course Home.View All Videos", + "edx.bi.app.course.home.view_all_videos.clicked" + ), + COURSE_HOME_VIEW_ALL_ASSIGNMENTS( + "Course Home.View All Assignments", + "edx.bi.app.course.home.view_all_assignments.clicked" + ), + COURSE_HOME_GRADES_VIEW_PROGRESS( + "Course Home.Grades.View Progress", + "edx.bi.app.course.home.grades.view_progress.clicked" + ), + COURSE_HOME_VIDEO_CLICK( + "Course Home.Video Click", + "edx.bi.app.course.home.video.clicked" + ), + COURSE_HOME_ASSIGNMENT_CLICK( + "Course Home.Assignment Click", + "edx.bi.app.course.home.assignment.clicked" + ), } enum class CourseAnalyticsKey(val key: String) { diff --git a/course/src/main/java/org/openedx/course/presentation/home/CourseHomeScreen.kt b/course/src/main/java/org/openedx/course/presentation/home/CourseHomeScreen.kt index 3baec6026..1c7f8de3d 100644 --- a/course/src/main/java/org/openedx/course/presentation/home/CourseHomeScreen.kt +++ b/course/src/main/java/org/openedx/course/presentation/home/CourseHomeScreen.kt @@ -99,12 +99,13 @@ fun CourseHomeScreen( uiMessage = uiMessage, homePagerState = homePagerState, onSubSectionClick = { subSectionBlock -> + // Log section/subsection click event + viewModel.logSectionSubsectionClick( + subSectionBlock.blockId, + subSectionBlock.displayName + ) if (viewModel.isCourseDropdownNavigationEnabled) { viewModel.courseSubSectionUnit[subSectionBlock.id]?.let { unit -> - viewModel.logUnitDetailViewedEvent( - unit.blockId, - unit.displayName - ) viewModel.courseRouter.navigateToCourseContainer( fragmentManager, courseId = viewModel.courseId, @@ -113,10 +114,6 @@ fun CourseHomeScreen( ) } } else { - viewModel.sequentialClickedEvent( - subSectionBlock.blockId, - subSectionBlock.displayName - ) viewModel.courseRouter.navigateToCourseSubsections( fm = fragmentManager, courseId = viewModel.courseId, @@ -169,7 +166,11 @@ fun CourseHomeScreen( }, onNavigateToContent = onNavigateToContent, onNavigateToProgress = onNavigateToProgress, - getBlockParent = viewModel::getBlockParent + getBlockParent = viewModel::getBlockParent, + onViewAllContentClick = viewModel::logViewAllContentClick, + onViewAllVideosClick = viewModel::logViewAllVideosClick, + onViewAllAssignmentsClick = viewModel::logViewAllAssignmentsClick, + onViewProgressClick = viewModel::logViewProgressClick ) } @@ -189,6 +190,10 @@ private fun CourseHomeUI( onNavigateToContent: (CourseContentTab) -> Unit, onNavigateToProgress: () -> Unit, getBlockParent: (blockId: String) -> Block?, + onViewAllContentClick: () -> Unit, + onViewAllVideosClick: () -> Unit, + onViewAllAssignmentsClick: () -> Unit, + onViewProgressClick: () -> Unit, ) { val scaffoldState = rememberScaffoldState() @@ -299,6 +304,7 @@ private fun CourseHomeUI( CourseCompletionHomePagerCardContent( uiState = uiState, onViewAllContentClick = { + onViewAllContentClick() onNavigateToContent(CourseContentTab.ALL) }, onDownloadClick = onDownloadClick, @@ -311,6 +317,7 @@ private fun CourseHomeUI( uiState = uiState, onVideoClick = onVideoClick, onViewAllVideosClick = { + onViewAllVideosClick() onNavigateToContent(CourseContentTab.VIDEOS) } ) @@ -322,6 +329,7 @@ private fun CourseHomeUI( onAssignmentClick = onAssignmentClick, getBlockParent = getBlockParent, onViewAllAssignmentsClick = { + onViewAllAssignmentsClick() onNavigateToContent(CourseContentTab.ASSIGNMENTS) } ) @@ -330,7 +338,10 @@ private fun CourseHomeUI( CourseHomePagerTab.GRADES -> { GradesHomePagerCardContent( uiState = uiState, - onViewProgressClick = onNavigateToProgress + onViewProgressClick = { + onViewProgressClick() + onNavigateToProgress() + } ) } } @@ -458,6 +469,10 @@ private fun CourseHomeScreenPreview() { onNavigateToContent = { _ -> }, onNavigateToProgress = {}, getBlockParent = { null }, + onViewAllContentClick = {}, + onViewAllVideosClick = {}, + onViewAllAssignmentsClick = {}, + onViewProgressClick = {}, ) } } @@ -507,6 +522,10 @@ private fun CourseHomeScreenTabletPreview() { onNavigateToContent = { _ -> }, onNavigateToProgress = { }, getBlockParent = { null }, + onViewAllContentClick = {}, + onViewAllVideosClick = {}, + onViewAllAssignmentsClick = {}, + onViewProgressClick = {}, ) } } diff --git a/course/src/main/java/org/openedx/course/presentation/home/CourseHomeViewModel.kt b/course/src/main/java/org/openedx/course/presentation/home/CourseHomeViewModel.kt index 12f0f27fc..f5b7c20d7 100644 --- a/course/src/main/java/org/openedx/course/presentation/home/CourseHomeViewModel.kt +++ b/course/src/main/java/org/openedx/course/presentation/home/CourseHomeViewModel.kt @@ -417,62 +417,6 @@ class CourseHomeViewModel( } } - fun viewCertificateTappedEvent() { - analytics.logEvent( - CourseAnalyticsEvent.VIEW_CERTIFICATE.eventName, - buildMap { - put(CourseAnalyticsKey.NAME.key, CourseAnalyticsEvent.VIEW_CERTIFICATE.biValue) - put(CourseAnalyticsKey.COURSE_ID.key, courseId) - } - ) - } - - private fun resumeCourseTappedEvent(blockId: String) { - val currentState = uiState.value - if (currentState is CourseHomeUIState.CourseData) { - analytics.logEvent( - CourseAnalyticsEvent.RESUME_COURSE_CLICKED.eventName, - buildMap { - put( - CourseAnalyticsKey.NAME.key, - CourseAnalyticsEvent.RESUME_COURSE_CLICKED.biValue - ) - put(CourseAnalyticsKey.COURSE_ID.key, courseId) - put(CourseAnalyticsKey.COURSE_NAME.key, courseTitle) - put(CourseAnalyticsKey.BLOCK_ID.key, blockId) - } - ) - } - } - - fun sequentialClickedEvent(blockId: String, blockName: String) { - val currentState = uiState.value - if (currentState is CourseHomeUIState.CourseData) { - analytics.sequentialClickedEvent( - courseId, - currentState.courseStructure.name, - blockId, - blockName - ) - } - } - - fun logUnitDetailViewedEvent(blockId: String, blockName: String) { - val currentState = uiState.value - if (currentState is CourseHomeUIState.CourseData) { - analytics.logEvent( - CourseAnalyticsEvent.UNIT_DETAIL.eventName, - buildMap { - put(CourseAnalyticsKey.NAME.key, CourseAnalyticsEvent.UNIT_DETAIL.biValue) - put(CourseAnalyticsKey.COURSE_ID.key, courseId) - put(CourseAnalyticsKey.COURSE_NAME.key, courseTitle) - put(CourseAnalyticsKey.BLOCK_ID.key, blockId) - put(CourseAnalyticsKey.BLOCK_NAME.key, blockName) - } - ) - } - } - fun downloadBlocks(blocksIds: List, fragmentManager: FragmentManager) { viewModelScope.launch { val courseData = _uiState.value as? CourseHomeUIState.CourseData ?: return@launch @@ -560,13 +504,14 @@ class CourseHomeViewModel( val currentState = uiState.value if (currentState is CourseHomeUIState.CourseData) { analytics.logEvent( - CourseAnalyticsEvent.COURSE_CONTENT_VIDEO_CLICK.eventName, + CourseAnalyticsEvent.COURSE_HOME_VIDEO_CLICK.eventName, buildMap { put( CourseAnalyticsKey.NAME.key, - CourseAnalyticsEvent.COURSE_CONTENT_VIDEO_CLICK.biValue + CourseAnalyticsEvent.COURSE_HOME_VIDEO_CLICK.biValue ) put(CourseAnalyticsKey.COURSE_ID.key, courseId) + put(CourseAnalyticsKey.COURSE_NAME.key, currentState.courseStructure.name) put(CourseAnalyticsKey.BLOCK_ID.key, blockId) } ) @@ -577,14 +522,130 @@ class CourseHomeViewModel( val currentState = uiState.value if (currentState is CourseHomeUIState.CourseData) { analytics.logEvent( - CourseAnalyticsEvent.COURSE_CONTENT_ASSIGNMENT_CLICK.eventName, + CourseAnalyticsEvent.COURSE_HOME_ASSIGNMENT_CLICK.eventName, + buildMap { + put( + CourseAnalyticsKey.NAME.key, + CourseAnalyticsEvent.COURSE_HOME_ASSIGNMENT_CLICK.biValue + ) + put(CourseAnalyticsKey.COURSE_ID.key, courseId) + put(CourseAnalyticsKey.COURSE_NAME.key, currentState.courseStructure.name) + put(CourseAnalyticsKey.BLOCK_ID.key, blockId) + } + ) + } + } + + fun viewCertificateTappedEvent() { + analytics.logEvent( + CourseAnalyticsEvent.VIEW_CERTIFICATE.eventName, + buildMap { + put(CourseAnalyticsKey.NAME.key, CourseAnalyticsEvent.VIEW_CERTIFICATE.biValue) + put(CourseAnalyticsKey.COURSE_ID.key, courseId) + } + ) + } + + private fun resumeCourseTappedEvent(blockId: String) { + val currentState = uiState.value + if (currentState is CourseHomeUIState.CourseData) { + analytics.logEvent( + CourseAnalyticsEvent.RESUME_COURSE_CLICKED.eventName, + buildMap { + put( + CourseAnalyticsKey.NAME.key, + CourseAnalyticsEvent.RESUME_COURSE_CLICKED.biValue + ) + put(CourseAnalyticsKey.COURSE_ID.key, courseId) + put(CourseAnalyticsKey.COURSE_NAME.key, courseTitle) + put(CourseAnalyticsKey.BLOCK_ID.key, blockId) + } + ) + } + } + + fun logSectionSubsectionClick(blockId: String, blockName: String) { + val currentState = uiState.value + if (currentState is CourseHomeUIState.CourseData) { + analytics.logEvent( + CourseAnalyticsEvent.COURSE_HOME_SECTION_SUBSECTION_CLICK.eventName, buildMap { put( CourseAnalyticsKey.NAME.key, - CourseAnalyticsEvent.COURSE_CONTENT_ASSIGNMENT_CLICK.biValue + CourseAnalyticsEvent.COURSE_HOME_SECTION_SUBSECTION_CLICK.biValue ) put(CourseAnalyticsKey.COURSE_ID.key, courseId) + put(CourseAnalyticsKey.COURSE_NAME.key, currentState.courseStructure.name) put(CourseAnalyticsKey.BLOCK_ID.key, blockId) + put(CourseAnalyticsKey.BLOCK_NAME.key, blockName) + } + ) + } + } + + fun logViewAllContentClick() { + val currentState = uiState.value + if (currentState is CourseHomeUIState.CourseData) { + analytics.logEvent( + CourseAnalyticsEvent.COURSE_HOME_VIEW_ALL_CONTENT.eventName, + buildMap { + put( + CourseAnalyticsKey.NAME.key, + CourseAnalyticsEvent.COURSE_HOME_VIEW_ALL_CONTENT.biValue + ) + put(CourseAnalyticsKey.COURSE_ID.key, courseId) + put(CourseAnalyticsKey.COURSE_NAME.key, currentState.courseStructure.name) + } + ) + } + } + + fun logViewAllVideosClick() { + val currentState = uiState.value + if (currentState is CourseHomeUIState.CourseData) { + analytics.logEvent( + CourseAnalyticsEvent.COURSE_HOME_VIEW_ALL_VIDEOS.eventName, + buildMap { + put( + CourseAnalyticsKey.NAME.key, + CourseAnalyticsEvent.COURSE_HOME_VIEW_ALL_VIDEOS.biValue + ) + put(CourseAnalyticsKey.COURSE_ID.key, courseId) + put(CourseAnalyticsKey.COURSE_NAME.key, currentState.courseStructure.name) + } + ) + } + } + + fun logViewAllAssignmentsClick() { + val currentState = uiState.value + if (currentState is CourseHomeUIState.CourseData) { + analytics.logEvent( + CourseAnalyticsEvent.COURSE_HOME_VIEW_ALL_ASSIGNMENTS.eventName, + buildMap { + put( + CourseAnalyticsKey.NAME.key, + CourseAnalyticsEvent.COURSE_HOME_VIEW_ALL_ASSIGNMENTS.biValue + ) + put(CourseAnalyticsKey.COURSE_ID.key, courseId) + put(CourseAnalyticsKey.COURSE_NAME.key, currentState.courseStructure.name) + } + ) + } + } + + fun logViewProgressClick() { + val currentState = uiState.value + if (currentState is CourseHomeUIState.CourseData) { + analytics.logEvent( + CourseAnalyticsEvent.COURSE_HOME_GRADES_VIEW_PROGRESS.eventName, + buildMap { + put( + CourseAnalyticsKey.NAME.key, + CourseAnalyticsEvent.COURSE_HOME_GRADES_VIEW_PROGRESS.biValue + ) + put(CourseAnalyticsKey.COURSE_ID.key, courseId) + put(CourseAnalyticsKey.COURSE_NAME.key, currentState.courseStructure.name) } ) } From a408106a0871d32e59b38308e7cc9df098a2a98a Mon Sep 17 00:00:00 2001 From: PavloNetrebchuk Date: Fri, 5 Sep 2025 18:34:43 +0300 Subject: [PATCH 13/17] feat: CourseHomeViewModelTest --- core/src/main/java/org/openedx/core/Mock.kt | 179 ++++ .../home/CourseHomeViewModelTest.kt | 845 ++++++++++++++++++ 2 files changed, 1024 insertions(+) create mode 100644 course/src/test/java/org/openedx/course/presentation/home/CourseHomeViewModelTest.kt diff --git a/core/src/main/java/org/openedx/core/Mock.kt b/core/src/main/java/org/openedx/core/Mock.kt index 5c34861ee..445fc7a05 100644 --- a/core/src/main/java/org/openedx/core/Mock.kt +++ b/core/src/main/java/org/openedx/core/Mock.kt @@ -1,12 +1,24 @@ package org.openedx.core +import org.openedx.core.data.model.room.VideoProgressEntity import org.openedx.core.domain.model.AssignmentProgress import org.openedx.core.domain.model.Block import org.openedx.core.domain.model.BlockCounts +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.CourseProgress import org.openedx.core.domain.model.CourseStructure import org.openedx.core.domain.model.CoursewareAccess +import org.openedx.core.domain.model.EncodedVideos import org.openedx.core.domain.model.OfflineDownload import org.openedx.core.domain.model.Progress +import org.openedx.core.domain.model.ResetCourseDates +import org.openedx.core.domain.model.StudentViewData +import org.openedx.core.domain.model.VideoInfo +import org.openedx.core.module.db.DownloadModel +import org.openedx.core.module.db.DownloadedState +import org.openedx.core.module.db.FileType import java.util.Date object Mock { @@ -81,4 +93,171 @@ object Mock { isSelfPaced = false, progress = Progress(1, 3), ) + + val mockCourseComponentStatus = CourseComponentStatus( + lastVisitedBlockId = "video1" + ) + + val mockCourseDatesBannerInfo = CourseDatesBannerInfo( + missedDeadlines = false, + missedGatedContent = false, + contentTypeGatingEnabled = false, + verifiedUpgradeLink = "", + hasEnded = false + ) + + val mockCourseDatesResult = CourseDatesResult( + datesSection = linkedMapOf(), + courseBanner = mockCourseDatesBannerInfo + ) + + val mockCourseProgress = CourseProgress( + verifiedMode = "audit", + accessExpiration = "", + certificateData = null, + completionSummary = null, + courseGrade = null, + creditCourseRequirements = "", + end = "", + enrollmentMode = "audit", + gradingPolicy = null, + hasScheduledContent = false, + sectionScores = emptyList(), + studioUrl = "", + username = "testuser", + userHasPassingGrade = false, + verificationData = null, + disableProgressGraph = false + ) + + val mockVideoProgress = VideoProgressEntity( + blockId = "video1", + videoUrl = "test-video-url", + videoTime = 1000L, + duration = 5000L + ) + + val mockResetCourseDates = ResetCourseDates( + message = "Dates reset successfully", + body = "Your course dates have been reset", + header = "Success", + link = "", + linkText = "" + ) + + val mockDownloadModel = DownloadModel( + id = "video1", + title = "Video 1", + courseId = "test-course-id", + size = 1000L, + path = "/test/path/video1", + url = "test-url", + type = FileType.VIDEO, + downloadedState = DownloadedState.NOT_DOWNLOADED, + lastModified = null + ) + + val mockVideoBlock = Block( + id = "video1", + blockId = "video1", + lmsWebUrl = "lmsWebUrl", + legacyWebUrl = "legacyWebUrl", + studentViewUrl = "studentViewUrl", + type = BlockType.VIDEO, + displayName = "Video 1", + graded = false, + studentViewData = StudentViewData( + onlyOnWeb = false, + duration = "", + transcripts = null, + encodedVideos = EncodedVideos( + youtube = null, + hls = null, + fallback = null, + desktopMp4 = null, + mobileHigh = null, + mobileLow = VideoInfo( + url = "test-url", + fileSize = 1000L + ) + ), + topicId = "" + ), + studentViewMultiDevice = false, + blockCounts = BlockCounts(0), + descendants = emptyList(), + descendantsType = BlockType.VIDEO, + completion = 0.0, + containsGatedContent = false, + assignmentProgress = null, + due = null, + offlineDownload = null, + ) + + val mockSequentialBlockForDownload = Block( + id = "sequential1", + blockId = "sequential1", + lmsWebUrl = "lmsWebUrl", + legacyWebUrl = "legacyWebUrl", + studentViewUrl = "studentViewUrl", + type = BlockType.SEQUENTIAL, + displayName = "Sequential 1", + graded = false, + studentViewData = null, + studentViewMultiDevice = false, + blockCounts = BlockCounts(0), + descendants = listOf("vertical1"), + descendantsType = BlockType.VERTICAL, + completion = 0.0, + containsGatedContent = false, + assignmentProgress = null, + due = null, + offlineDownload = null, + ) + + val mockVerticalBlock = Block( + id = "vertical1", + blockId = "vertical1", + lmsWebUrl = "lmsWebUrl", + legacyWebUrl = "legacyWebUrl", + studentViewUrl = "studentViewUrl", + type = BlockType.VERTICAL, + displayName = "Vertical 1", + graded = false, + studentViewData = null, + studentViewMultiDevice = false, + blockCounts = BlockCounts(0), + descendants = listOf("video1"), + descendantsType = BlockType.VIDEO, + completion = 0.0, + containsGatedContent = false, + assignmentProgress = null, + due = null, + offlineDownload = null, + ) + + val mockCourseStructureForDownload = CourseStructure( + root = "sequential1", + blockData = listOf(mockSequentialBlockForDownload, mockVerticalBlock, mockVideoBlock), + id = "test-course-id", + name = "Test Course", + number = "CS101", + org = "TestOrg", + start = Date(), + startDisplay = "2024-01-01", + startType = "timestamped", + end = Date(), + coursewareAccess = CoursewareAccess( + true, + "", + "", + "", + "", + "" + ), + media = null, + certificate = null, + isSelfPaced = false, + progress = null + ) } diff --git a/course/src/test/java/org/openedx/course/presentation/home/CourseHomeViewModelTest.kt b/course/src/test/java/org/openedx/course/presentation/home/CourseHomeViewModelTest.kt new file mode 100644 index 000000000..73dad5ed7 --- /dev/null +++ b/course/src/test/java/org/openedx/course/presentation/home/CourseHomeViewModelTest.kt @@ -0,0 +1,845 @@ +package org.openedx.course.presentation.home + +import android.content.Context +import androidx.arch.core.executor.testing.InstantTaskExecutorRule +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.every +import io.mockk.mockk +import io.mockk.verify +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TestRule +import org.openedx.core.Mock +import org.openedx.core.R +import org.openedx.core.config.Config +import org.openedx.core.data.storage.CorePreferences +import org.openedx.core.module.DownloadWorkerController +import org.openedx.core.module.db.DownloadDao +import org.openedx.core.module.download.DownloadHelper +import org.openedx.core.presentation.CoreAnalytics +import org.openedx.core.presentation.dialog.downloaddialog.DownloadDialogManager +import org.openedx.core.system.connection.NetworkConnection +import org.openedx.core.system.notifier.CourseDatesShifted +import org.openedx.core.system.notifier.CourseNotifier +import org.openedx.core.system.notifier.CourseOpenBlock +import org.openedx.core.system.notifier.CourseProgressLoaded +import org.openedx.core.system.notifier.CourseStructureUpdated +import org.openedx.course.domain.interactor.CourseInteractor +import org.openedx.course.presentation.CourseAnalytics +import org.openedx.course.presentation.CourseAnalyticsEvent +import org.openedx.course.presentation.CourseAnalyticsKey +import org.openedx.course.presentation.CourseRouter +import org.openedx.foundation.system.ResourceManager +import org.openedx.foundation.utils.FileUtil +import java.net.UnknownHostException + +@OptIn(ExperimentalCoroutinesApi::class) +class CourseHomeViewModelTest { + + @get:Rule + val testInstantTaskExecutorRule: TestRule = InstantTaskExecutorRule() + + private val dispatcher = StandardTestDispatcher() + + private val courseId = "test-course-id" + private val courseTitle = "Test Course" + private val context = mockk() + private val config = mockk() + private val interactor = mockk() + private val resourceManager = mockk() + private val courseNotifier = mockk() + private val networkConnection = mockk() + private val preferencesManager = mockk() + private val analytics = mockk() + private val downloadDialogManager = mockk() + private val fileUtil = mockk() + private val courseRouter = mockk() + private val coreAnalytics = mockk() + private val downloadDao = mockk() + private val workerController = mockk() + private val downloadHelper = mockk() + + private val noInternet = "Slow or no internet connection" + private val somethingWrong = "Something went wrong" + private val cantDownload = "You can download content only from Wi-fi" + + private val courseStructure = Mock.mockCourseStructure.copy( + id = courseId, + name = courseTitle + ) + private val courseComponentStatus = Mock.mockCourseComponentStatus + private val courseDatesResult = Mock.mockCourseDatesResult + private val courseProgress = Mock.mockCourseProgress + private val videoProgress = Mock.mockVideoProgress + private val resetCourseDates = Mock.mockResetCourseDates + + @Before + fun setUp() { + Dispatchers.setMain(dispatcher) + + every { resourceManager.getString(R.string.core_error_no_connection) } returns noInternet + every { resourceManager.getString(R.string.core_error_unknown_error) } returns somethingWrong + every { resourceManager.getString(org.openedx.course.R.string.course_can_download_only_with_wifi) } returns cantDownload + every { resourceManager.getString(R.string.core_dates_shift_dates_unsuccessful_msg) } returns "Failed to shift dates" + + every { config.getCourseUIConfig().isCourseDropdownNavigationEnabled } returns true + every { config.getCourseUIConfig().isCourseDownloadQueueEnabled } returns true + + every { preferencesManager.isRelativeDatesEnabled } returns true + every { preferencesManager.videoSettings.wifiDownloadOnly } returns false + + every { networkConnection.isWifiConnected() } returns true + every { networkConnection.isOnline() } returns true + + every { fileUtil.getExternalAppDir().path } returns "/test/path" + + every { downloadDao.getAllDataFlow() } returns flow { emit(emptyList()) } + + every { courseNotifier.notifier } returns flow { } + coEvery { courseNotifier.send(any()) } returns Unit + + every { analytics.logEvent(any(), any()) } returns Unit + every { coreAnalytics.logEvent(any(), any()) } returns Unit + + every { + downloadDialogManager.showPopup( + any(), + any(), + any(), + any(), + any(), + any() + ) + } returns Unit + + coEvery { workerController.saveModels(any()) } returns Unit + } + + @After + fun tearDown() { + Dispatchers.resetMain() + } + + @Test + fun `getCourseData success`() = runTest { + coEvery { interactor.getCourseStructureFlow(courseId, false) } returns flow { + emit( + courseStructure + ) + } + coEvery { interactor.getCourseStatusFlow(courseId) } returns flow { + emit( + courseComponentStatus + ) + } + coEvery { interactor.getCourseDatesFlow(courseId) } returns flow { emit(courseDatesResult) } + coEvery { + interactor.getCourseProgress( + courseId, + false + ) + } returns flow { emit(courseProgress) } + coEvery { interactor.getVideoProgress("video1") } returns videoProgress + + val viewModel = CourseHomeViewModel( + courseId = courseId, + courseTitle = courseTitle, + context = context, + config = config, + interactor = interactor, + resourceManager = resourceManager, + courseNotifier = courseNotifier, + networkConnection = networkConnection, + preferencesManager = preferencesManager, + analytics = analytics, + downloadDialogManager = downloadDialogManager, + fileUtil = fileUtil, + courseRouter = courseRouter, + coreAnalytics = coreAnalytics, + downloadDao = downloadDao, + workerController = workerController, + downloadHelper = downloadHelper + ) + + advanceUntilIdle() + + coVerify { interactor.getCourseStructureFlow(courseId, false) } + coVerify { interactor.getCourseStatusFlow(courseId) } + coVerify { interactor.getCourseDatesFlow(courseId) } + coVerify { interactor.getCourseProgress(courseId, false) } + + assertTrue(viewModel.uiState.value is CourseHomeUIState.CourseData) + val courseData = viewModel.uiState.value as CourseHomeUIState.CourseData + assertEquals(courseId, courseData.courseStructure.id) + assertEquals(courseTitle, courseData.courseStructure.name) + assertEquals(courseProgress, courseData.courseProgress) + } + + @Test + fun `getCourseData no internet connection error`() = runTest { + coEvery { + interactor.getCourseStructureFlow( + courseId, + false + ) + } returns flow { throw UnknownHostException() } + coEvery { interactor.getCourseStatusFlow(courseId) } returns flow { + emit( + courseComponentStatus + ) + } + coEvery { interactor.getCourseDatesFlow(courseId) } returns flow { emit(courseDatesResult) } + coEvery { + interactor.getCourseProgress( + courseId, + false + ) + } returns flow { emit(courseProgress) } + + val viewModel = CourseHomeViewModel( + courseId = courseId, + courseTitle = courseTitle, + context = context, + config = config, + interactor = interactor, + resourceManager = resourceManager, + courseNotifier = courseNotifier, + networkConnection = networkConnection, + preferencesManager = preferencesManager, + analytics = analytics, + downloadDialogManager = downloadDialogManager, + fileUtil = fileUtil, + courseRouter = courseRouter, + coreAnalytics = coreAnalytics, + downloadDao = downloadDao, + workerController = workerController, + downloadHelper = downloadHelper + ) + + advanceUntilIdle() + + assertTrue(viewModel.uiState.value !is CourseHomeUIState.CourseData) + } + + @Test + fun `getCourseData unknown error`() = runTest { + coEvery { + interactor.getCourseStructureFlow( + courseId, + false + ) + } returns flow { throw Exception("Unknown error") } + coEvery { interactor.getCourseStatusFlow(courseId) } returns flow { + emit( + courseComponentStatus + ) + } + coEvery { interactor.getCourseDatesFlow(courseId) } returns flow { emit(courseDatesResult) } + coEvery { + interactor.getCourseProgress( + courseId, + false + ) + } returns flow { emit(courseProgress) } + + val viewModel = CourseHomeViewModel( + courseId = courseId, + courseTitle = courseTitle, + context = context, + config = config, + interactor = interactor, + resourceManager = resourceManager, + courseNotifier = courseNotifier, + networkConnection = networkConnection, + preferencesManager = preferencesManager, + analytics = analytics, + downloadDialogManager = downloadDialogManager, + fileUtil = fileUtil, + courseRouter = courseRouter, + coreAnalytics = coreAnalytics, + downloadDao = downloadDao, + workerController = workerController, + downloadHelper = downloadHelper + ) + + advanceUntilIdle() + + assertTrue(viewModel.uiState.value !is CourseHomeUIState.CourseData) + } + + @Test + fun `saveDownloadModels with wifi only enabled but no wifi connection`() = runTest { + every { preferencesManager.videoSettings.wifiDownloadOnly } returns true + every { networkConnection.isWifiConnected() } returns false + + coEvery { interactor.getCourseStructureFlow(courseId, false) } returns flow { + emit( + courseStructure + ) + } + coEvery { interactor.getCourseStatusFlow(courseId) } returns flow { + emit( + courseComponentStatus + ) + } + coEvery { interactor.getCourseDatesFlow(courseId) } returns flow { emit(courseDatesResult) } + coEvery { + interactor.getCourseProgress( + courseId, + false + ) + } returns flow { emit(courseProgress) } + + val viewModel = CourseHomeViewModel( + courseId = courseId, + courseTitle = courseTitle, + context = context, + config = config, + interactor = interactor, + resourceManager = resourceManager, + courseNotifier = courseNotifier, + networkConnection = networkConnection, + preferencesManager = preferencesManager, + analytics = analytics, + downloadDialogManager = downloadDialogManager, + fileUtil = fileUtil, + courseRouter = courseRouter, + coreAnalytics = coreAnalytics, + downloadDao = downloadDao, + workerController = workerController, + downloadHelper = downloadHelper + ) + + advanceUntilIdle() + + viewModel.saveDownloadModels("/test/path", courseId, "test-block-id") + + coVerify(exactly = 0) { workerController.saveModels(any()) } + } + + @Test + fun `resetCourseDatesBanner success`() = runTest { + coEvery { interactor.resetCourseDates(courseId) } returns resetCourseDates + coEvery { interactor.getCourseStructureFlow(courseId, false) } returns flow { + emit( + courseStructure + ) + } + coEvery { interactor.getCourseStatusFlow(courseId) } returns flow { + emit( + courseComponentStatus + ) + } + coEvery { interactor.getCourseDatesFlow(courseId) } returns flow { emit(courseDatesResult) } + coEvery { + interactor.getCourseProgress( + courseId, + false + ) + } returns flow { emit(courseProgress) } + + val viewModel = CourseHomeViewModel( + courseId = courseId, + courseTitle = courseTitle, + context = context, + config = config, + interactor = interactor, + resourceManager = resourceManager, + courseNotifier = courseNotifier, + networkConnection = networkConnection, + preferencesManager = preferencesManager, + analytics = analytics, + downloadDialogManager = downloadDialogManager, + fileUtil = fileUtil, + courseRouter = courseRouter, + coreAnalytics = coreAnalytics, + downloadDao = downloadDao, + workerController = workerController, + downloadHelper = downloadHelper + ) + + advanceUntilIdle() + + var resetResult: Boolean? = null + + viewModel.resetCourseDatesBanner { success -> + resetResult = success + } + + advanceUntilIdle() + + coVerify { interactor.resetCourseDates(courseId) } + coVerify { courseNotifier.send(CourseDatesShifted) } + assertEquals(true, resetResult) + } + + @Test + fun `resetCourseDatesBanner with internet error`() = runTest { + coEvery { interactor.resetCourseDates(courseId) } throws UnknownHostException() + coEvery { interactor.getCourseStructureFlow(courseId, false) } returns flow { + emit( + courseStructure + ) + } + coEvery { interactor.getCourseStatusFlow(courseId) } returns flow { + emit( + courseComponentStatus + ) + } + coEvery { interactor.getCourseDatesFlow(courseId) } returns flow { emit(courseDatesResult) } + coEvery { + interactor.getCourseProgress( + courseId, + false + ) + } returns flow { emit(courseProgress) } + + val viewModel = CourseHomeViewModel( + courseId = courseId, + courseTitle = courseTitle, + context = context, + config = config, + interactor = interactor, + resourceManager = resourceManager, + courseNotifier = courseNotifier, + networkConnection = networkConnection, + preferencesManager = preferencesManager, + analytics = analytics, + downloadDialogManager = downloadDialogManager, + fileUtil = fileUtil, + courseRouter = courseRouter, + coreAnalytics = coreAnalytics, + downloadDao = downloadDao, + workerController = workerController, + downloadHelper = downloadHelper + ) + + advanceUntilIdle() + + var resetResult: Boolean? = null + + viewModel.resetCourseDatesBanner { success -> + resetResult = success + } + + advanceUntilIdle() + + coVerify { interactor.resetCourseDates(courseId) } + coVerify(exactly = 0) { courseNotifier.send(CourseDatesShifted) } + assertEquals(false, resetResult) + } + + @Test + fun `logVideoClick analytics event`() = runTest { + coEvery { interactor.getCourseStructureFlow(courseId, false) } returns flow { + emit( + courseStructure + ) + } + coEvery { interactor.getCourseStatusFlow(courseId) } returns flow { + emit( + courseComponentStatus + ) + } + coEvery { interactor.getCourseDatesFlow(courseId) } returns flow { emit(courseDatesResult) } + coEvery { + interactor.getCourseProgress( + courseId, + false + ) + } returns flow { emit(courseProgress) } + + val viewModel = CourseHomeViewModel( + courseId = courseId, + courseTitle = courseTitle, + context = context, + config = config, + interactor = interactor, + resourceManager = resourceManager, + courseNotifier = courseNotifier, + networkConnection = networkConnection, + preferencesManager = preferencesManager, + analytics = analytics, + downloadDialogManager = downloadDialogManager, + fileUtil = fileUtil, + courseRouter = courseRouter, + coreAnalytics = coreAnalytics, + downloadDao = downloadDao, + workerController = workerController, + downloadHelper = downloadHelper + ) + + advanceUntilIdle() + + viewModel.logVideoClick("video1") + + verify { + analytics.logEvent( + CourseAnalyticsEvent.COURSE_HOME_VIDEO_CLICK.eventName, + match { + it[CourseAnalyticsKey.NAME.key] == CourseAnalyticsEvent.COURSE_HOME_VIDEO_CLICK.biValue && + it[CourseAnalyticsKey.COURSE_ID.key] == courseId && + it[CourseAnalyticsKey.COURSE_NAME.key] == courseTitle && + it[CourseAnalyticsKey.BLOCK_ID.key] == "video1" + } + ) + } + } + + @Test + fun `logAssignmentClick analytics event`() = runTest { + coEvery { interactor.getCourseStructureFlow(courseId, false) } returns flow { + emit( + courseStructure + ) + } + coEvery { interactor.getCourseStatusFlow(courseId) } returns flow { + emit( + courseComponentStatus + ) + } + coEvery { interactor.getCourseDatesFlow(courseId) } returns flow { emit(courseDatesResult) } + coEvery { + interactor.getCourseProgress( + courseId, + false + ) + } returns flow { emit(courseProgress) } + + val viewModel = CourseHomeViewModel( + courseId = courseId, + courseTitle = courseTitle, + context = context, + config = config, + interactor = interactor, + resourceManager = resourceManager, + courseNotifier = courseNotifier, + networkConnection = networkConnection, + preferencesManager = preferencesManager, + analytics = analytics, + downloadDialogManager = downloadDialogManager, + fileUtil = fileUtil, + courseRouter = courseRouter, + coreAnalytics = coreAnalytics, + downloadDao = downloadDao, + workerController = workerController, + downloadHelper = downloadHelper + ) + + advanceUntilIdle() + + viewModel.logAssignmentClick("assignment1") + + verify { + analytics.logEvent( + CourseAnalyticsEvent.COURSE_HOME_ASSIGNMENT_CLICK.eventName, + match { + it[CourseAnalyticsKey.NAME.key] == CourseAnalyticsEvent.COURSE_HOME_ASSIGNMENT_CLICK.biValue && + it[CourseAnalyticsKey.COURSE_ID.key] == courseId && + it[CourseAnalyticsKey.COURSE_NAME.key] == courseTitle && + it[CourseAnalyticsKey.BLOCK_ID.key] == "assignment1" + } + ) + } + } + + @Test + fun `viewCertificateTappedEvent analytics event`() = runTest { + coEvery { interactor.getCourseStructureFlow(courseId, false) } returns flow { + emit( + courseStructure + ) + } + coEvery { interactor.getCourseStatusFlow(courseId) } returns flow { + emit( + courseComponentStatus + ) + } + coEvery { interactor.getCourseDatesFlow(courseId) } returns flow { emit(courseDatesResult) } + coEvery { + interactor.getCourseProgress( + courseId, + false + ) + } returns flow { emit(courseProgress) } + + val viewModel = CourseHomeViewModel( + courseId = courseId, + courseTitle = courseTitle, + context = context, + config = config, + interactor = interactor, + resourceManager = resourceManager, + courseNotifier = courseNotifier, + networkConnection = networkConnection, + preferencesManager = preferencesManager, + analytics = analytics, + downloadDialogManager = downloadDialogManager, + fileUtil = fileUtil, + courseRouter = courseRouter, + coreAnalytics = coreAnalytics, + downloadDao = downloadDao, + workerController = workerController, + downloadHelper = downloadHelper + ) + + advanceUntilIdle() + + viewModel.viewCertificateTappedEvent() + + verify { + analytics.logEvent( + CourseAnalyticsEvent.VIEW_CERTIFICATE.eventName, + match { + it[CourseAnalyticsKey.NAME.key] == CourseAnalyticsEvent.VIEW_CERTIFICATE.biValue && + it[CourseAnalyticsKey.COURSE_ID.key] == courseId + } + ) + } + } + + @Test + fun `getCourseProgress success`() = runTest { + coEvery { interactor.getCourseStructureFlow(courseId, false) } returns flow { + emit( + courseStructure + ) + } + coEvery { interactor.getCourseStatusFlow(courseId) } returns flow { + emit( + courseComponentStatus + ) + } + coEvery { interactor.getCourseDatesFlow(courseId) } returns flow { emit(courseDatesResult) } + coEvery { + interactor.getCourseProgress( + courseId, + false + ) + } returns flow { emit(courseProgress) } + + val viewModel = CourseHomeViewModel( + courseId = courseId, + courseTitle = courseTitle, + context = context, + config = config, + interactor = interactor, + resourceManager = resourceManager, + courseNotifier = courseNotifier, + networkConnection = networkConnection, + preferencesManager = preferencesManager, + analytics = analytics, + downloadDialogManager = downloadDialogManager, + fileUtil = fileUtil, + courseRouter = courseRouter, + coreAnalytics = coreAnalytics, + downloadDao = downloadDao, + workerController = workerController, + downloadHelper = downloadHelper + ) + + advanceUntilIdle() + + viewModel.getCourseProgress() + + coVerify { interactor.getCourseProgress(courseId, false) } + } + + @Test + fun `CourseStructureUpdated notifier event`() = runTest { + coEvery { interactor.getCourseStructureFlow(courseId, false) } returns flow { + emit( + courseStructure + ) + } + coEvery { interactor.getCourseStatusFlow(courseId) } returns flow { + emit( + courseComponentStatus + ) + } + coEvery { interactor.getCourseDatesFlow(courseId) } returns flow { emit(courseDatesResult) } + coEvery { + interactor.getCourseProgress( + courseId, + false + ) + } returns flow { emit(courseProgress) } + + every { courseNotifier.notifier } returns flow { emit(CourseStructureUpdated(courseId)) } + + val viewModel = CourseHomeViewModel( + courseId = courseId, + courseTitle = courseTitle, + context = context, + config = config, + interactor = interactor, + resourceManager = resourceManager, + courseNotifier = courseNotifier, + networkConnection = networkConnection, + preferencesManager = preferencesManager, + analytics = analytics, + downloadDialogManager = downloadDialogManager, + fileUtil = fileUtil, + courseRouter = courseRouter, + coreAnalytics = coreAnalytics, + downloadDao = downloadDao, + workerController = workerController, + downloadHelper = downloadHelper + ) + + advanceUntilIdle() + + coVerify(atLeast = 2) { interactor.getCourseStructureFlow(courseId, false) } + } + + @Test + fun `CourseOpenBlock notifier event`() = runTest { + coEvery { interactor.getCourseStructureFlow(courseId, false) } returns flow { + emit( + courseStructure + ) + } + coEvery { interactor.getCourseStatusFlow(courseId) } returns flow { + emit( + courseComponentStatus + ) + } + coEvery { interactor.getCourseDatesFlow(courseId) } returns flow { emit(courseDatesResult) } + coEvery { + interactor.getCourseProgress( + courseId, + false + ) + } returns flow { emit(courseProgress) } + + every { courseNotifier.notifier } returns flow { emit(CourseOpenBlock("test-block-id")) } + + val viewModel = CourseHomeViewModel( + courseId = courseId, + courseTitle = courseTitle, + context = context, + config = config, + interactor = interactor, + resourceManager = resourceManager, + courseNotifier = courseNotifier, + networkConnection = networkConnection, + preferencesManager = preferencesManager, + analytics = analytics, + downloadDialogManager = downloadDialogManager, + fileUtil = fileUtil, + courseRouter = courseRouter, + coreAnalytics = coreAnalytics, + downloadDao = downloadDao, + workerController = workerController, + downloadHelper = downloadHelper + ) + + advanceUntilIdle() + + } + + @Test + fun `CourseProgressLoaded notifier event`() = runTest { + coEvery { interactor.getCourseStructureFlow(courseId, false) } returns flow { + emit( + courseStructure + ) + } + coEvery { interactor.getCourseStatusFlow(courseId) } returns flow { + emit( + courseComponentStatus + ) + } + coEvery { interactor.getCourseDatesFlow(courseId) } returns flow { emit(courseDatesResult) } + coEvery { + interactor.getCourseProgress( + courseId, + false + ) + } returns flow { emit(courseProgress) } + + every { courseNotifier.notifier } returns flow { emit(CourseProgressLoaded) } + + val viewModel = CourseHomeViewModel( + courseId = courseId, + courseTitle = courseTitle, + context = context, + config = config, + interactor = interactor, + resourceManager = resourceManager, + courseNotifier = courseNotifier, + networkConnection = networkConnection, + preferencesManager = preferencesManager, + analytics = analytics, + downloadDialogManager = downloadDialogManager, + fileUtil = fileUtil, + courseRouter = courseRouter, + coreAnalytics = coreAnalytics, + downloadDao = downloadDao, + workerController = workerController, + downloadHelper = downloadHelper + ) + + advanceUntilIdle() + + coVerify(atLeast = 2) { interactor.getCourseProgress(courseId, false) } + } + + @Test + fun `isCourseDropdownNavigationEnabled property`() = runTest { + every { config.getCourseUIConfig().isCourseDropdownNavigationEnabled } returns true + + coEvery { interactor.getCourseStructureFlow(courseId, false) } returns flow { + emit( + courseStructure + ) + } + coEvery { interactor.getCourseStatusFlow(courseId) } returns flow { + emit( + courseComponentStatus + ) + } + coEvery { interactor.getCourseDatesFlow(courseId) } returns flow { emit(courseDatesResult) } + coEvery { + interactor.getCourseProgress( + courseId, + false + ) + } returns flow { emit(courseProgress) } + + val viewModel = CourseHomeViewModel( + courseId = courseId, + courseTitle = courseTitle, + context = context, + config = config, + interactor = interactor, + resourceManager = resourceManager, + courseNotifier = courseNotifier, + networkConnection = networkConnection, + preferencesManager = preferencesManager, + analytics = analytics, + downloadDialogManager = downloadDialogManager, + fileUtil = fileUtil, + courseRouter = courseRouter, + coreAnalytics = coreAnalytics, + downloadDao = downloadDao, + workerController = workerController, + downloadHelper = downloadHelper + ) + + assertTrue(viewModel.isCourseDropdownNavigationEnabled) + } +} From 14b9923895539398baa639f658488e300c462c2a Mon Sep 17 00:00:00 2001 From: PavloNetrebchuk Date: Mon, 8 Sep 2025 13:45:22 +0300 Subject: [PATCH 14/17] fix: detekt fix and changes according PR review --- .../main/java/org/openedx/app/AppActivity.kt | 10 +- .../core/domain/model/CourseProgress.kt | 8 +- .../org/openedx/core/extension/ListExt.kt | 18 +++ .../org/openedx/core/ui/theme/AppColors.kt | 2 + .../java/org/openedx/core/ui/theme/Theme.kt | 8 +- .../java/org/openedx/core/utils/TimeUtils.kt | 2 +- .../org/openedx/core/ui/theme/Colors.kt | 6 +- .../CourseContentAssignmentScreen.kt | 8 +- .../container/CourseContainerFragment.kt | 14 +- .../home/AssignmentsHomePagerCardContent.kt | 2 +- .../CourseCompletionHomePagerCardContent.kt | 1 + .../presentation/home/CourseHomeScreen.kt | 4 +- .../presentation/home/CourseHomeViewModel.kt | 4 +- .../home/GradesHomePagerCardContent.kt | 24 +++- .../home/VideosHomePagerCardContent.kt | 53 +++++--- .../progress/CourseProgressScreen.kt | 10 +- .../course/presentation/ui/CourseUI.kt | 121 ++++++++++-------- course/src/main/res/values/strings.xml | 2 +- .../home/CourseHomeViewModelTest.kt | 15 ++- 19 files changed, 203 insertions(+), 109 deletions(-) diff --git a/app/src/main/java/org/openedx/app/AppActivity.kt b/app/src/main/java/org/openedx/app/AppActivity.kt index 11b913d50..b904bf6a1 100644 --- a/app/src/main/java/org/openedx/app/AppActivity.kt +++ b/app/src/main/java/org/openedx/app/AppActivity.kt @@ -2,7 +2,9 @@ package org.openedx.app import android.content.Intent import android.content.res.Configuration +import android.graphics.Color import android.net.Uri +import android.os.Build import android.os.Bundle import android.view.View import android.view.WindowManager @@ -158,8 +160,12 @@ class AppActivity : AppCompatActivity(), InsetHolder, WindowSizeHolder { WindowCompat.setDecorFitsSystemWindows(this, false) val insetsController = WindowInsetsControllerCompat(this, binding.root) insetsController.isAppearanceLightStatusBars = !isUsingNightModeResources() - insetsController.systemBarsBehavior = - WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.VANILLA_ICE_CREAM) { + insetsController.systemBarsBehavior = + WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE + } else { + window.statusBarColor = Color.TRANSPARENT + } } } 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 f4d1639cd..0e3ceea48 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 @@ -47,13 +47,15 @@ data class CourseProgress( } fun getAssignmentGradedPercent(type: String): Float { - val assignmentSections = sectionScores - .flatMap { it.subsections } - .filter { it.assignmentType == type } + val assignmentSections = getAssignmentSections(type) if (assignmentSections.isEmpty()) return 0f return assignmentSections.sumOf { it.percentGraded }.toFloat() / assignmentSections.size } + fun getAssignmentSections(type: String) = sectionScores + .flatMap { it.subsections } + .filter { it.assignmentType == type } + fun getAssignmentWeightedGradedPercent(assignmentPolicy: GradingPolicy.AssignmentPolicy): Float { return (assignmentPolicy.weight * getAssignmentGradedPercent(assignmentPolicy.type) * 100f).toFloat() } diff --git a/core/src/main/java/org/openedx/core/extension/ListExt.kt b/core/src/main/java/org/openedx/core/extension/ListExt.kt index 6a802755f..f5cc21279 100644 --- a/core/src/main/java/org/openedx/core/extension/ListExt.kt +++ b/core/src/main/java/org/openedx/core/extension/ListExt.kt @@ -14,3 +14,21 @@ fun List.getSequentialBlocks(): List { fun List.getChapterBlocks(): List { return this.filter { it.type == BlockType.CHAPTER } } + +fun List.getUnitChapter(blockId: String): Block? { + val verticalBlock = this.firstOrNull { + it.type == BlockType.VERTICAL && it.descendants.contains(blockId) + } + + val sequentialBlock = verticalBlock?.let { vertical -> + this.firstOrNull { + it.type == BlockType.SEQUENTIAL && it.descendants.contains(vertical.id) + } + } + + return sequentialBlock?.let { sequential -> + this.firstOrNull { + it.type == BlockType.CHAPTER && it.descendants.contains(sequential.id) + } + } +} 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 143bfabf7..bf20366d9 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 @@ -80,6 +80,8 @@ data class AppColors( val progressBarColor: Color, val progressBarBackgroundColor: Color, val gradeProgressBarBorder: Color, + val gradeProgressBarBackground: Color, + val assignmentCardBorder: 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 c4f54ac17..5dc1f2575 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 @@ -97,7 +97,9 @@ private val DarkColorPalette = AppColors( progressBarColor = dark_progress_bar_color, progressBarBackgroundColor = dark_progress_bar_background_color, - gradeProgressBarBorder = dark_grade_progress_bar_color + gradeProgressBarBorder = dark_grade_progress_bar_color, + gradeProgressBarBackground = dark_grade_progress_bar_background, + assignmentCardBorder = dark_assignment_card_border, ) private val LightColorPalette = AppColors( @@ -187,7 +189,9 @@ private val LightColorPalette = AppColors( progressBarColor = light_progress_bar_color, progressBarBackgroundColor = light_progress_bar_background_color, - gradeProgressBarBorder = light_grade_progress_bar_color + gradeProgressBarBorder = light_grade_progress_bar_color, + gradeProgressBarBackground = light_grade_progress_bar_background, + assignmentCardBorder = light_assignment_card_border, ) val MaterialTheme.appColors: AppColors diff --git a/core/src/main/java/org/openedx/core/utils/TimeUtils.kt b/core/src/main/java/org/openedx/core/utils/TimeUtils.kt index ad6ef7fff..9ab3ba354 100644 --- a/core/src/main/java/org/openedx/core/utils/TimeUtils.kt +++ b/core/src/main/java/org/openedx/core/utils/TimeUtils.kt @@ -97,7 +97,7 @@ object TimeUtils { } } - fun formatToDayMonth(date: Date): String { + fun formatToMonthDay(date: Date): String { val sdf = SimpleDateFormat("MMM dd", Locale.getDefault()) return sdf.format(date) } 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 df4f6c357..28ed4ca95 100644 --- a/core/src/openedx/org/openedx/core/ui/theme/Colors.kt +++ b/core/src/openedx/org/openedx/core/ui/theme/Colors.kt @@ -48,7 +48,7 @@ val light_divider = Color(0xFFCCD4E0) val light_certificate_foreground = Color(0xD94BD191) val light_bottom_sheet_toggle = Color(0xFF4E5A70) val light_warning = Color(0xFFFFC94D) -val light_info = Color(0xFF42AAFF) +val light_info = Color(0xFF3A9AE9) val light_rate_stars = Color(0xFFFFC94D) val light_inactive_button_background = Color(0xFFCCD4E0) val light_success_green = Color(0xFF198571) @@ -75,6 +75,8 @@ val light_settings_title_content = Color.White val light_progress_bar_color = light_success_green val light_progress_bar_background_color = Color(0xFFCCD4E0) val light_grade_progress_bar_color = Color.Black +val light_grade_progress_bar_background = light_background +val light_assignment_card_border = Color(0xFFCCD4E0) val dark_primary = Color(0xFF3F68F8) val dark_primary_variant = Color(0xFF3700B3) @@ -149,3 +151,5 @@ val dark_settings_title_content = Color.White val dark_progress_bar_color = dark_success_green val dark_progress_bar_background_color = Color(0xFF8E9BAE) val dark_grade_progress_bar_color = Color.Transparent +val dark_grade_progress_bar_background = Color(0xFF8E9BAE) +val dark_assignment_card_border = Color(0xFF8E9BAE) diff --git a/course/src/main/java/org/openedx/course/presentation/assignments/CourseContentAssignmentScreen.kt b/course/src/main/java/org/openedx/course/presentation/assignments/CourseContentAssignmentScreen.kt index 95ed45003..204639bc9 100644 --- a/course/src/main/java/org/openedx/course/presentation/assignments/CourseContentAssignmentScreen.kt +++ b/course/src/main/java/org/openedx/course/presentation/assignments/CourseContentAssignmentScreen.kt @@ -268,7 +268,7 @@ private fun AssignmentGroupSection( if (isCompletedShown || progress.value != 1f) { if (assignments.size > 1) { LazyRow( - horizontalArrangement = Arrangement.spacedBy(8.dp), + horizontalArrangement = Arrangement.spacedBy(12.dp), modifier = Modifier.padding(vertical = 8.dp), contentPadding = PaddingValues(horizontal = 24.dp) ) { @@ -303,7 +303,7 @@ private fun AssignmentGroupSection( AssignmentDetails( modifier = Modifier .padding(horizontal = 24.dp) - .padding(top = 8.dp), + .padding(top = 12.dp), assignment = assignment, sectionName = sectionNames[assignment.id] ?: "", onAssignmentClick = onAssignmentClick @@ -429,7 +429,7 @@ private fun AssignmentDetails( val color = when { assignment.isCompleted() -> MaterialTheme.appColors.successGreen isDuePast -> MaterialTheme.appColors.warning - else -> MaterialTheme.appColors.cardViewBorder + else -> MaterialTheme.appColors.assignmentCardBorder } val label = assignment.assignmentProgress?.label val description = when { @@ -495,12 +495,14 @@ private fun AssignmentDetails( color = MaterialTheme.appColors.textDark ) Text( + modifier = Modifier.padding(top = 4.dp), text = assignment.displayName, style = MaterialTheme.appTypography.bodyLarge, color = MaterialTheme.appColors.textDark ) if (description.isNotEmpty()) { Text( + modifier = Modifier.padding(top = 6.dp), text = description, style = MaterialTheme.appTypography.bodySmall, color = MaterialTheme.appColors.textDark 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 89212a06d..57e0a3be4 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 @@ -483,18 +483,18 @@ private fun DashboardPager( onNavigateToContent = { contentTab -> scope.launch { // First scroll to CONTENT tab - pagerState.animateScrollToPage( + pagerState.scrollToPage( CourseContainerTab.entries.indexOf(CourseContainerTab.CONTENT) ) // Then scroll to the specified content tab - contentTabPagerState.animateScrollToPage( + contentTabPagerState.scrollToPage( CourseContentTab.entries.indexOf(contentTab) ) } }, onNavigateToProgress = { scope.launch { - pagerState.animateScrollToPage( + pagerState.scrollToPage( CourseContainerTab.entries.indexOf(CourseContainerTab.PROGRESS) ) } @@ -582,7 +582,7 @@ private fun DashboardPager( onTabSelected = onContentTabSelected, onNavigateToHome = { scope.launch { - pagerState.animateScrollToPage( + pagerState.scrollToPage( CourseContainerTab.entries.indexOf( CourseContainerTab.HOME ) @@ -711,14 +711,14 @@ private fun SetupCourseAccessErrorButtons( @OptIn(ExperimentalFoundationApi::class) private fun scrollToDates(scope: CoroutineScope, pagerState: PagerState) { scope.launch { - pagerState.animateScrollToPage(CourseContainerTab.entries.indexOf(CourseContainerTab.DATES)) + pagerState.scrollToPage(CourseContainerTab.entries.indexOf(CourseContainerTab.DATES)) } } @OptIn(ExperimentalFoundationApi::class) private fun scrollToProgress(scope: CoroutineScope, pagerState: PagerState) { scope.launch { - pagerState.animateScrollToPage(CourseContainerTab.entries.indexOf(CourseContainerTab.PROGRESS)) + pagerState.scrollToPage(CourseContainerTab.entries.indexOf(CourseContainerTab.PROGRESS)) } } @@ -734,6 +734,7 @@ private fun HomeNavigationRow(homePagerState: PagerState) { ) { val isPreviousPageEnabled = homePagerState.currentPage > 0 IconButton( + modifier = Modifier.size(60.dp), enabled = homePagerState.currentPage > 0, onClick = { homeCoroutineScope.launch { @@ -762,6 +763,7 @@ private fun HomeNavigationRow(homePagerState: PagerState) { ) val isNextPageEnabled = homePagerState.currentPage < CourseHomePagerTab.entries.size - 1 IconButton( + modifier = Modifier.size(60.dp), enabled = isNextPageEnabled, onClick = { homeCoroutineScope.launch { diff --git a/course/src/main/java/org/openedx/course/presentation/home/AssignmentsHomePagerCardContent.kt b/course/src/main/java/org/openedx/course/presentation/home/AssignmentsHomePagerCardContent.kt index b5ffcb8dd..686dd2de1 100644 --- a/course/src/main/java/org/openedx/course/presentation/home/AssignmentsHomePagerCardContent.kt +++ b/course/src/main/java/org/openedx/course/presentation/home/AssignmentsHomePagerCardContent.kt @@ -163,7 +163,7 @@ private fun AssignmentCard( // Due date status text val dueDateStatusText = assignment.due?.let { due -> - val formattedDate = TimeUtils.formatToDayMonth(due) + val formattedDate = TimeUtils.formatToMonthDay(due) val daysDifference = ((due.time - Date().time) / MILLISECONDS_PER_DAY).toInt() when { daysDifference < 0 -> { diff --git a/course/src/main/java/org/openedx/course/presentation/home/CourseCompletionHomePagerCardContent.kt b/course/src/main/java/org/openedx/course/presentation/home/CourseCompletionHomePagerCardContent.kt index d1cd730be..679d0f391 100644 --- a/course/src/main/java/org/openedx/course/presentation/home/CourseCompletionHomePagerCardContent.kt +++ b/course/src/main/java/org/openedx/course/presentation/home/CourseCompletionHomePagerCardContent.kt @@ -104,6 +104,7 @@ fun CourseCompletionHomePagerCardContent( }, isExpandable = false, isSectionVisible = true, + showDueDate = false, useRelativeDates = uiState.useRelativeDates, subSections = listOf(subsection), downloadedStateMap = uiState.downloadedState, diff --git a/course/src/main/java/org/openedx/course/presentation/home/CourseHomeScreen.kt b/course/src/main/java/org/openedx/course/presentation/home/CourseHomeScreen.kt index 1c7f8de3d..b4adcc443 100644 --- a/course/src/main/java/org/openedx/course/presentation/home/CourseHomeScreen.kt +++ b/course/src/main/java/org/openedx/course/presentation/home/CourseHomeScreen.kt @@ -397,14 +397,14 @@ fun ViewAllButton( Icon( imageVector = Icons.AutoMirrored.Filled.List, contentDescription = null, - tint = MaterialTheme.appColors.primary, + tint = MaterialTheme.appColors.textAccent, modifier = Modifier.size(20.dp) ) Spacer(modifier = Modifier.width(8.dp)) Text( text = text, style = MaterialTheme.appTypography.labelLarge, - color = MaterialTheme.appColors.primary + color = MaterialTheme.appColors.textAccent ) } } diff --git a/course/src/main/java/org/openedx/course/presentation/home/CourseHomeViewModel.kt b/course/src/main/java/org/openedx/course/presentation/home/CourseHomeViewModel.kt index f5b7c20d7..33bceff3e 100644 --- a/course/src/main/java/org/openedx/course/presentation/home/CourseHomeViewModel.kt +++ b/course/src/main/java/org/openedx/course/presentation/home/CourseHomeViewModel.kt @@ -222,7 +222,7 @@ class CourseHomeViewModel( ) courseAssignments.addAll(allAssignments) - val sortedStructure = courseStructure.copy(blockData = sortBlocks(blocks)) + sortBlocks(blocks) initDownloadModelsStatus() val nextSection = findFirstChapterWithIncompleteDescendants(blocks) @@ -248,7 +248,7 @@ class CourseHomeViewModel( } _uiState.value = CourseHomeUIState.CourseData( - courseStructure = sortedStructure, + courseStructure = courseStructure, next = nextSection, downloadedState = getDownloadModelsStatus(), resumeComponent = getResumeBlock(blocks, courseStatus.lastVisitedBlockId), diff --git a/course/src/main/java/org/openedx/course/presentation/home/GradesHomePagerCardContent.kt b/course/src/main/java/org/openedx/course/presentation/home/GradesHomePagerCardContent.kt index dab39ef31..a8129c1ab 100644 --- a/course/src/main/java/org/openedx/course/presentation/home/GradesHomePagerCardContent.kt +++ b/course/src/main/java/org/openedx/course/presentation/home/GradesHomePagerCardContent.kt @@ -21,6 +21,7 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.contentDescription import androidx.compose.ui.semantics.semantics import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow @@ -43,6 +44,10 @@ fun GradesHomePagerCardContent( val courseProgress = uiState.courseProgress val gradingPolicy = courseProgress?.gradingPolicy val assignmentPolicies = courseProgress?.getNotEmptyGradingPolicies() + val requiredGradeString = stringResource( + R.string.course_progress_required_grade_percent, + courseProgress?.requiredGradePercent.toString() + ) if (courseProgress == null || gradingPolicy == null || assignmentPolicies.isNullOrEmpty()) { CourseHomeGradesEmptyState() @@ -69,12 +74,19 @@ fun GradesHomePagerCardContent( Spacer(modifier = Modifier.height(16.dp)) CurrentOverallGradeText(progress = courseProgress) Spacer(modifier = Modifier.height(12.dp)) - GradeProgressBar( - progress = courseProgress, - gradingPolicy = gradingPolicy, - notCompletedWeightedGradePercent = courseProgress.getNotCompletedWeightedGradePercent() - ) - RequiredGradeMarker(progress = courseProgress) + Column( + modifier = Modifier + .semantics { + contentDescription = requiredGradeString + } + ) { + GradeProgressBar( + progress = courseProgress, + gradingPolicy = gradingPolicy, + notCompletedWeightedGradePercent = courseProgress.getNotCompletedWeightedGradePercent() + ) + RequiredGradeMarker(progress = courseProgress) + } Spacer(modifier = Modifier.height(20.dp)) GradeCardsGrid( assignmentPolicies = gradingPolicy.assignmentPolicies, diff --git a/course/src/main/java/org/openedx/course/presentation/home/VideosHomePagerCardContent.kt b/course/src/main/java/org/openedx/course/presentation/home/VideosHomePagerCardContent.kt index 57a916d42..9a9e2c907 100644 --- a/course/src/main/java/org/openedx/course/presentation/home/VideosHomePagerCardContent.kt +++ b/course/src/main/java/org/openedx/course/presentation/home/VideosHomePagerCardContent.kt @@ -1,6 +1,6 @@ package org.openedx.course.presentation.home -import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer @@ -10,6 +10,7 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material.Card import androidx.compose.material.Icon import androidx.compose.material.LinearProgressIndicator import androidx.compose.material.MaterialTheme @@ -25,7 +26,9 @@ import androidx.compose.ui.semantics.semantics import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import org.openedx.core.domain.model.Block +import org.openedx.core.extension.getUnitChapter 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.course.presentation.contenttab.CourseContentVideoEmptyState @@ -118,21 +121,41 @@ fun VideosHomePagerCardContent( Spacer(modifier = Modifier.height(8.dp)) // Video card using CourseVideoItem - Box( - modifier = Modifier.fillMaxWidth(), - contentAlignment = Alignment.Center - ) { - CourseVideoItem( - modifier = Modifier - .fillMaxWidth() - .height(180.dp), - videoBlock = firstIncompleteVideo, - preview = uiState.videoPreview, - progress = uiState.videoProgress, - onClick = { - onVideoClick(firstIncompleteVideo) - } + Card( + modifier = Modifier + .fillMaxWidth(), + backgroundColor = MaterialTheme.appColors.cardViewBackground, + shape = MaterialTheme.appShapes.videoPreviewShape, + elevation = 0.dp, + border = BorderStroke( + width = 1.dp, + color = MaterialTheme.appColors.cardViewBorder ) + ) { + Column { + CourseVideoItem( + modifier = Modifier + .fillMaxWidth() + .height(180.dp), + videoBlock = firstIncompleteVideo, + preview = uiState.videoPreview, + progress = uiState.videoProgress, + onClick = { + onVideoClick(firstIncompleteVideo) + }, + titleStyle = MaterialTheme.appTypography.titleMedium, + contentModifier = Modifier + .padding(horizontal = 16.dp, vertical = 8.dp), + progressModifier = Modifier.height(8.dp), + ) + Text( + modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp), + text = uiState.courseStructure.blockData + .getUnitChapter(firstIncompleteVideo.id)?.displayName ?: "", + style = MaterialTheme.appTypography.labelMedium, + color = MaterialTheme.appColors.textPrimary, + ) + } } } else { CaughtUpMessage( 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 613511252..5fbada78d 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 @@ -355,8 +355,9 @@ private fun AssignmentTypeRow( policy: CourseProgress.GradingPolicy.AssignmentPolicy, color: Color ) { - val earned = progress.getEarnedAssignmentProblems(policy) - val possible = progress.getPossibleAssignmentProblems(policy) + val assignments = progress.getAssignmentSections(policy.type) + val earned = assignments.filter { it.numPointsEarned > 0f }.size //Is it correct? + val possible = assignments.size Column( modifier = Modifier .semantics(mergeDescendants = true) {} @@ -390,8 +391,8 @@ private fun AssignmentTypeRow( Text( text = stringResource( R.string.course_progress_earned_possible_assignment_problems, - earned.toInt(), - possible.toInt() + earned, + possible ), style = MaterialTheme.appTypography.bodySmall, color = MaterialTheme.appColors.textDark, @@ -519,6 +520,7 @@ fun GradeProgressBar( Box( modifier = Modifier .weight(notCompletedWeightedGradePercent) + .background(MaterialTheme.appColors.gradeProgressBarBackground) .fillMaxHeight() ) } diff --git a/course/src/main/java/org/openedx/course/presentation/ui/CourseUI.kt b/course/src/main/java/org/openedx/course/presentation/ui/CourseUI.kt index 3a27f17ed..1776c7286 100644 --- a/course/src/main/java/org/openedx/course/presentation/ui/CourseUI.kt +++ b/course/src/main/java/org/openedx/course/presentation/ui/CourseUI.kt @@ -79,6 +79,7 @@ import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextDecoration @@ -666,7 +667,8 @@ fun CourseVideoSection( CourseVideoItem( modifier = Modifier .width(192.dp) - .height(108.dp), + .height(108.dp) + .clip(MaterialTheme.appShapes.videoPreviewShape), videoBlock = block, preview = preview[block.id], progress = progress[block.id] ?: 0f, @@ -686,11 +688,13 @@ fun CourseVideoItem( videoBlock: Block, preview: VideoPreview?, progress: Float, - onClick: () -> Unit + onClick: () -> Unit, + titleStyle: TextStyle = MaterialTheme.appTypography.bodySmall, + contentModifier: Modifier = Modifier.padding(8.dp), + progressModifier: Modifier = Modifier.height(4.dp), ) { Box( modifier = modifier - .clip(MaterialTheme.appShapes.videoPreviewShape) .let { if (videoBlock.isCompleted()) { it.border( @@ -731,58 +735,64 @@ fun CourseVideoItem( ) ) - Image( - modifier = Modifier - .size(32.dp) - .align(Alignment.Center), - painter = painterResource(id = R.drawable.course_video_play_button), - contentDescription = null, - ) - - // Title (top-left) - Text( - text = videoBlock.displayName, - color = Color.White, - style = MaterialTheme.appTypography.bodySmall, - modifier = Modifier - .align(Alignment.TopStart) - .padding(8.dp), - maxLines = 2, - overflow = TextOverflow.Ellipsis - ) + Box( + modifier = contentModifier.fillMaxSize() + ) { + Image( + modifier = Modifier + .size(32.dp) + .align(Alignment.Center), + painter = painterResource(id = R.drawable.course_video_play_button), + contentDescription = null, + ) - // Progress bar (bottom) - if (progress > 0.0f) { - Box( + // Title (top-left) + Text( + text = videoBlock.displayName, + color = Color.White, + style = titleStyle, modifier = Modifier - .padding(bottom = 4.dp) - .height(16.dp) - .align(Alignment.BottomCenter), - contentAlignment = Alignment.Center - ) { - LinearProgressIndicator( + .align(Alignment.TopStart), + maxLines = 2, + overflow = TextOverflow.Ellipsis + ) + + // Progress bar (bottom) + if (progress > 0.0f) { + Box( modifier = Modifier - .fillMaxWidth() - .height(4.dp) - .padding(horizontal = 8.dp) - .clip(CircleShape), - progress = progress, - color = if (videoBlock.isCompleted() && progress > 0.95f) { - MaterialTheme.appColors.progressBarColor - } else { - MaterialTheme.appColors.primary - }, - backgroundColor = MaterialTheme.appColors.progressBarBackgroundColor - ) - if (videoBlock.isCompleted()) { - Image( - modifier = Modifier - .align(Alignment.BottomEnd) - .size(16.dp) - .offset(x = (-4).dp), - painter = painterResource(id = coreR.drawable.ic_core_check), - contentDescription = stringResource(R.string.course_accessibility_video_watched), + .align(Alignment.BottomCenter), + contentAlignment = Alignment.Center + ) { + LinearProgressIndicator( + modifier = progressModifier + .fillMaxWidth() + .clip(CircleShape), + progress = progress, + color = if (videoBlock.isCompleted() && progress > 0.95f) { + MaterialTheme.appColors.progressBarColor + } else { + MaterialTheme.appColors.info + }, + backgroundColor = MaterialTheme.appColors.progressBarBackgroundColor ) + if (videoBlock.isCompleted()) { + Image( + modifier = Modifier + .align(Alignment.BottomEnd) + .size(16.dp) + .offset(x = 1.dp), + painter = painterResource(id = coreR.drawable.ic_core_check), + contentDescription = stringResource(R.string.course_accessibility_video_watched), + ) + } else { + Box( + modifier = Modifier + .align(Alignment.BottomEnd) + .size(16.dp) + .offset(x = 1.dp), + ) + } } } } @@ -855,7 +865,7 @@ fun DownloadIcon( val downloadIconTint = if (downloadedState == DownloadedState.DOWNLOADED) { MaterialTheme.appColors.successGreen } else { - MaterialTheme.appColors.textAccent + MaterialTheme.appColors.primary } IconButton( modifier = iconModifier, @@ -907,6 +917,7 @@ fun CourseSection( modifier: Modifier = Modifier, section: Block, useRelativeDates: Boolean, + showDueDate: Boolean = true, isExpandable: Boolean = true, onItemClick: (Block) -> Unit, isSectionVisible: Boolean?, @@ -973,6 +984,7 @@ fun CourseSection( CourseSubSectionItem( block = subSectionBlock, onClick = onSubSectionClick, + showDueDate = showDueDate, useRelativeDates = useRelativeDates ) } @@ -1033,6 +1045,7 @@ fun CourseSubSectionItem( modifier: Modifier = Modifier, block: Block, useRelativeDates: Boolean, + showDueDate: Boolean, onClick: (Block) -> Unit, ) { val context = LocalContext.current @@ -1076,7 +1089,7 @@ fun CourseSubSectionItem( maxLines = 1 ) Spacer(modifier = Modifier.width(16.dp)) - if (due != null) { + if (due != null || showDueDate) { Icon( imageVector = Icons.AutoMirrored.Filled.KeyboardArrowRight, tint = MaterialTheme.appColors.onSurface, @@ -1104,7 +1117,7 @@ fun CourseSubSectionItem( .filter { !it.isNullOrEmpty() } .joinToString(" - ") - if (assignmentString.isNotEmpty()) { + if (assignmentString.isNotEmpty() && showDueDate) { Spacer(modifier = Modifier.height(8.dp)) Text( text = assignmentString, diff --git a/course/src/main/res/values/strings.xml b/course/src/main/res/values/strings.xml index 3bdc0839e..519647e56 100644 --- a/course/src/main/res/values/strings.xml +++ b/course/src/main/res/values/strings.xml @@ -85,7 +85,7 @@ %1$s/%2$s Completed Complete - %1$s points Past Due - %1$s points - In Progress - %1$s points + - %1$s points %1$s %% of Grade Review Course Grading Policy Return to Course Home diff --git a/course/src/test/java/org/openedx/course/presentation/home/CourseHomeViewModelTest.kt b/course/src/test/java/org/openedx/course/presentation/home/CourseHomeViewModelTest.kt index 73dad5ed7..d675091bb 100644 --- a/course/src/test/java/org/openedx/course/presentation/home/CourseHomeViewModelTest.kt +++ b/course/src/test/java/org/openedx/course/presentation/home/CourseHomeViewModelTest.kt @@ -46,14 +46,13 @@ import org.openedx.foundation.system.ResourceManager import org.openedx.foundation.utils.FileUtil import java.net.UnknownHostException +@Suppress("LargeClass") @OptIn(ExperimentalCoroutinesApi::class) class CourseHomeViewModelTest { @get:Rule val testInstantTaskExecutorRule: TestRule = InstantTaskExecutorRule() - private val dispatcher = StandardTestDispatcher() - private val courseId = "test-course-id" private val courseTitle = "Test Course" private val context = mockk() @@ -92,8 +91,12 @@ class CourseHomeViewModelTest { every { resourceManager.getString(R.string.core_error_no_connection) } returns noInternet every { resourceManager.getString(R.string.core_error_unknown_error) } returns somethingWrong - every { resourceManager.getString(org.openedx.course.R.string.course_can_download_only_with_wifi) } returns cantDownload - every { resourceManager.getString(R.string.core_dates_shift_dates_unsuccessful_msg) } returns "Failed to shift dates" + every { + resourceManager.getString(org.openedx.course.R.string.course_can_download_only_with_wifi) + } returns cantDownload + every { + resourceManager.getString(R.string.core_dates_shift_dates_unsuccessful_msg) + } returns "Failed to shift dates" every { config.getCourseUIConfig().isCourseDropdownNavigationEnabled } returns true every { config.getCourseUIConfig().isCourseDownloadQueueEnabled } returns true @@ -234,6 +237,7 @@ class CourseHomeViewModelTest { assertTrue(viewModel.uiState.value !is CourseHomeUIState.CourseData) } + @Suppress("TooGenericExceptionThrown") @Test fun `getCourseData unknown error`() = runTest { coEvery { @@ -241,7 +245,7 @@ class CourseHomeViewModelTest { courseId, false ) - } returns flow { throw Exception("Unknown error") } + } returns flow { throw Exception() } coEvery { interactor.getCourseStatusFlow(courseId) } returns flow { emit( courseComponentStatus @@ -748,7 +752,6 @@ class CourseHomeViewModelTest { ) advanceUntilIdle() - } @Test From 5e1b11ffc728c60084124c13b60eeee30da0b2fb Mon Sep 17 00:00:00 2001 From: PavloNetrebchuk Date: Tue, 16 Sep 2025 13:31:26 +0300 Subject: [PATCH 15/17] feat: performance improvements --- .../org/openedx/core/utils/PreviewHelper.kt | 29 ++++++++++- .../data/repository/CourseRepository.kt | 16 +++++-- .../domain/interactor/CourseInteractor.kt | 4 +- .../assignments/CourseAssignmentViewModel.kt | 2 +- .../CourseContentAssignmentScreen.kt | 8 +--- .../presentation/home/CourseHomeViewModel.kt | 27 +++++++---- .../progress/CourseProgressScreen.kt | 2 +- .../progress/CourseProgressViewModel.kt | 2 +- .../videos/CourseVideoViewModel.kt | 33 ++++++++----- .../home/CourseHomeViewModelTest.kt | 48 ++++++++++++------- 10 files changed, 116 insertions(+), 55 deletions(-) diff --git a/core/src/main/java/org/openedx/core/utils/PreviewHelper.kt b/core/src/main/java/org/openedx/core/utils/PreviewHelper.kt index 03227050b..dd3d65fdf 100644 --- a/core/src/main/java/org/openedx/core/utils/PreviewHelper.kt +++ b/core/src/main/java/org/openedx/core/utils/PreviewHelper.kt @@ -7,6 +7,9 @@ import android.media.MediaMetadataRetriever import java.io.File import java.io.FileOutputStream import java.security.MessageDigest +import java.util.concurrent.Executors +import java.util.concurrent.TimeUnit +import java.util.concurrent.TimeoutException data class VideoPreview( val link: String? = null, @@ -25,6 +28,9 @@ data class VideoPreview( object PreviewHelper { + private const val TIMEOUT_MS = 5000L // 5 seconds + private val executor = Executors.newSingleThreadExecutor() + fun getYouTubeThumbnailUrl(url: String): String { val videoId = extractYouTubeVideoId(url) return "https://img.youtube.com/vi/$videoId/0.jpg" @@ -49,15 +55,34 @@ object PreviewHelper { try { BitmapFactory.decodeFile(cacheFile.absolutePath) } catch (_: Exception) { - extractBitmapFromVideo(videoUrl, context) + // If cache file is corrupted, try to extract from video with timeout + extractBitmapFromVideoWithTimeout(videoUrl, context) } } else { - extractBitmapFromVideo(videoUrl, context) + // Extract from video with timeout + extractBitmapFromVideoWithTimeout(videoUrl, context) } } return result } + private fun extractBitmapFromVideoWithTimeout(videoUrl: String, context: Context): Bitmap? { + return try { + val future = executor.submit { + extractBitmapFromVideo(videoUrl, context) + } + future.get(TIMEOUT_MS, TimeUnit.MILLISECONDS) + } catch (e: TimeoutException) { + // Server didn't respond within timeout, return null immediately + e.printStackTrace() + null + } catch (e: Exception) { + // Any other exception, return null immediately + e.printStackTrace() + null + } + } + private fun extractBitmapFromVideo(videoUrl: String, context: Context): Bitmap? { val retriever = MediaMetadataRetriever() try { 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 03a25895f..4225f278b 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 @@ -6,6 +6,7 @@ import okhttp3.MultipartBody import org.openedx.core.ApiConstants import org.openedx.core.data.api.CourseApi import org.openedx.core.data.model.BlocksCompletionBody +import org.openedx.core.data.model.room.CourseProgressEntity import org.openedx.core.data.model.room.OfflineXBlockProgress import org.openedx.core.data.model.room.VideoProgressEntity import org.openedx.core.data.model.room.XBlockProgressData @@ -256,15 +257,20 @@ class CourseRepository( ?: VideoProgressEntity(blockId, "", 0L, 0L) } - fun getCourseProgress(courseId: String, isRefresh: Boolean): Flow = + fun getCourseProgress( + courseId: String, + isRefresh: Boolean, + getOnlyCacheIfExist: Boolean + ): Flow = channelFlowWithAwait { + var courseProgress: CourseProgressEntity? = null if (!isRefresh) { - val cached = courseDao.getCourseProgressById(courseId) - if (cached != null) { - trySend(cached.mapToDomain()) + courseProgress = courseDao.getCourseProgressById(courseId) + if (courseProgress != null) { + trySend(courseProgress.mapToDomain()) } } - if (networkConnection.isOnline()) { + if (networkConnection.isOnline() && (!getOnlyCacheIfExist || courseProgress == null)) { val response = api.getCourseProgress(courseId) courseDao.insertCourseProgressEntity(response.mapToRoomEntity(courseId)) trySend(response.mapToDomain()) 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 7da1623d7..86788f34c 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 @@ -120,8 +120,8 @@ class CourseInteractor( suspend fun submitOfflineXBlockProgress(blockId: String, courseId: String) = repository.submitOfflineXBlockProgress(blockId, courseId) - fun getCourseProgress(courseId: String, isRefresh: Boolean) = - repository.getCourseProgress(courseId, isRefresh) + fun getCourseProgress(courseId: String, isRefresh: Boolean, getOnlyCacheIfExist: Boolean) = + repository.getCourseProgress(courseId, isRefresh, getOnlyCacheIfExist) suspend fun getVideoProgress(blockId: String) = repository.getVideoProgress(blockId) } diff --git a/course/src/main/java/org/openedx/course/presentation/assignments/CourseAssignmentViewModel.kt b/course/src/main/java/org/openedx/course/presentation/assignments/CourseAssignmentViewModel.kt index 1e480e538..11da8d792 100644 --- a/course/src/main/java/org/openedx/course/presentation/assignments/CourseAssignmentViewModel.kt +++ b/course/src/main/java/org/openedx/course/presentation/assignments/CourseAssignmentViewModel.kt @@ -38,7 +38,7 @@ class CourseAssignmentViewModel( private fun collectData() { viewModelScope.launch { - val courseProgressFlow = interactor.getCourseProgress(courseId, false) + val courseProgressFlow = interactor.getCourseProgress(courseId, false, true) val courseStructureFlow = interactor.getCourseStructureFlow(courseId) combine( diff --git a/course/src/main/java/org/openedx/course/presentation/assignments/CourseContentAssignmentScreen.kt b/course/src/main/java/org/openedx/course/presentation/assignments/CourseContentAssignmentScreen.kt index 204639bc9..d1f8df535 100644 --- a/course/src/main/java/org/openedx/course/presentation/assignments/CourseContentAssignmentScreen.kt +++ b/course/src/main/java/org/openedx/course/presentation/assignments/CourseContentAssignmentScreen.kt @@ -148,7 +148,7 @@ private fun CourseContentAssignmentScreen( ) { val progress = uiState.progress val description = stringResource( - id = R.string.course_completed, + id = R.string.course_completed_of, progress.completed, progress.total ) @@ -277,11 +277,7 @@ private fun AssignmentGroupSection( assignment = assignment, isSelected = assignment.id == selectedId, onClick = { - selectedId = if (selectedId == assignment.id) { - null - } else { - assignment.id - } + selectedId = assignment.id } ) } diff --git a/course/src/main/java/org/openedx/course/presentation/home/CourseHomeViewModel.kt b/course/src/main/java/org/openedx/course/presentation/home/CourseHomeViewModel.kt index 33bceff3e..95ece8979 100644 --- a/course/src/main/java/org/openedx/course/presentation/home/CourseHomeViewModel.kt +++ b/course/src/main/java/org/openedx/course/presentation/home/CourseHomeViewModel.kt @@ -3,6 +3,7 @@ package org.openedx.course.presentation.home import android.content.Context import androidx.fragment.app.FragmentManager import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharedFlow @@ -175,7 +176,7 @@ class CourseHomeViewModel( .catch { emit(null) } val courseStatusFlow = interactor.getCourseStatusFlow(courseId) val courseDatesFlow = interactor.getCourseDatesFlow(courseId) - val courseProgressFlow = interactor.getCourseProgress(courseId, false) + val courseProgressFlow = interactor.getCourseProgress(courseId, false, true) combine( courseStructureFlow, courseStatusFlow, @@ -229,11 +230,6 @@ class CourseHomeViewModel( // Get video data val allVideos = courseVideos.values.flatten() val firstIncompleteVideo = allVideos.find { !it.isCompleted() } - val videoPreview = firstIncompleteVideo?.getVideoPreview( - context, - networkConnection.isOnline(), - null - ) val videoProgress = if (firstIncompleteVideo != null) { try { val videoProgressEntity = interactor.getVideoProgress(firstIncompleteVideo.id) @@ -260,9 +256,24 @@ class CourseHomeViewModel( courseProgress = courseProgress, courseVideos = courseVideos, courseAssignments = courseAssignments, - videoPreview = videoPreview, + videoPreview = (_uiState.value as? CourseHomeUIState.CourseData)?.videoPreview, videoProgress = videoProgress ) + getVideoPreview(firstIncompleteVideo) + } + + private fun getVideoPreview(videoBlock: Block?) { + viewModelScope.launch(Dispatchers.IO) { + val videoPreview = videoBlock?.getVideoPreview( + context, + networkConnection.isOnline(), + null + ) + _uiState.value = (_uiState.value as? CourseHomeUIState.CourseData) + ?.copy( + videoPreview = videoPreview + ) ?: return@launch + } } private suspend fun handleCourseDataError(e: Throwable?) { @@ -485,7 +496,7 @@ class CourseHomeViewModel( if (_uiState.value !is CourseHomeUIState.CourseData) { _uiState.value = CourseHomeUIState.Loading } - interactor.getCourseProgress(courseId, false) + interactor.getCourseProgress(courseId, false, true) .catch { e -> if (_uiState.value !is CourseHomeUIState.CourseData) { _uiState.value = CourseHomeUIState.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 5fbada78d..66fa746df 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 @@ -356,7 +356,7 @@ private fun AssignmentTypeRow( color: Color ) { val assignments = progress.getAssignmentSections(policy.type) - val earned = assignments.filter { it.numPointsEarned > 0f }.size //Is it correct? + val earned = assignments.filter { it.numPointsEarned > 0f }.size // Is it correct? val possible = assignments.size Column( modifier = Modifier 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 87a2dacf0..99bae2bcd 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( if (!isRefresh) { _uiState.value = CourseProgressUIState.Loading } - interactor.getCourseProgress(courseId, isRefresh) + interactor.getCourseProgress(courseId, isRefresh, getOnlyCacheIfExist = false) .catch { e -> if (_uiState.value !is CourseProgressUIState.Data) { _uiState.value = CourseProgressUIState.Error diff --git a/course/src/main/java/org/openedx/course/presentation/videos/CourseVideoViewModel.kt b/course/src/main/java/org/openedx/course/presentation/videos/CourseVideoViewModel.kt index c37b8709e..fc3e9577e 100644 --- a/course/src/main/java/org/openedx/course/presentation/videos/CourseVideoViewModel.kt +++ b/course/src/main/java/org/openedx/course/presentation/videos/CourseVideoViewModel.kt @@ -12,7 +12,6 @@ import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext import org.openedx.core.BlockType import org.openedx.core.config.Config import org.openedx.core.data.storage.CorePreferences @@ -148,16 +147,6 @@ class CourseVideoViewModel( courseSubSectionUnit.clear() courseStructure = courseStructure.copy(blockData = sortBlocks(blocks)) initDownloadModelsStatus() - val downloadingModels = getDownloadModelList() - val videoPreview = withContext(Dispatchers.IO) { - courseVideos.values.flatten().associate { block -> - block.id to block.getVideoPreview( - context, - networkConnection.isOnline(), - downloadingModels.find { block.id == it.id }?.path - ) - } - } val videoProgress = courseVideos.values.flatten().associate { block -> val videoProgressEntity = interactor.getVideoProgress(block.id) val progress = videoProgressEntity.videoTime.toFloat() @@ -176,11 +165,13 @@ class CourseVideoViewModel( subSectionsDownloadsCount = subSectionsDownloadsCount, downloadModelsSize = getDownloadModelsSize(), isCompletedSectionsShown = isCompletedSectionsShown, - videoPreview = videoPreview, + videoPreview = (_uiState.value as? CourseVideoUIState.CourseData)?.videoPreview + ?: emptyMap(), videoProgress = videoProgress, ) } courseNotifier.send(CourseLoading(false)) + getVideoPreviews() } catch (e: Exception) { e.printStackTrace() _uiState.value = CourseVideoUIState.Empty @@ -188,6 +179,24 @@ class CourseVideoViewModel( } } + private fun getVideoPreviews() { + viewModelScope.launch(Dispatchers.IO) { + val downloadingModels = getDownloadModelList() + courseVideos.values.flatten().forEach { block -> + val previewMap = block.id to block.getVideoPreview( + context, + networkConnection.isOnline(), + downloadingModels.find { block.id == it.id }?.path + ) + val currentUiState = + (_uiState.value as? CourseVideoUIState.CourseData) ?: return@forEach + _uiState.value = currentUiState.copy( + videoPreview = currentUiState.videoPreview + previewMap + ) + } + } + } + private fun sortBlocks(blocks: List): List { if (blocks.isEmpty()) return emptyList() diff --git a/course/src/test/java/org/openedx/course/presentation/home/CourseHomeViewModelTest.kt b/course/src/test/java/org/openedx/course/presentation/home/CourseHomeViewModelTest.kt index d675091bb..72e51c4d3 100644 --- a/course/src/test/java/org/openedx/course/presentation/home/CourseHomeViewModelTest.kt +++ b/course/src/test/java/org/openedx/course/presentation/home/CourseHomeViewModelTest.kt @@ -152,7 +152,8 @@ class CourseHomeViewModelTest { coEvery { interactor.getCourseProgress( courseId, - false + false, + true ) } returns flow { emit(courseProgress) } coEvery { interactor.getVideoProgress("video1") } returns videoProgress @@ -182,7 +183,7 @@ class CourseHomeViewModelTest { coVerify { interactor.getCourseStructureFlow(courseId, false) } coVerify { interactor.getCourseStatusFlow(courseId) } coVerify { interactor.getCourseDatesFlow(courseId) } - coVerify { interactor.getCourseProgress(courseId, false) } + coVerify { interactor.getCourseProgress(courseId, false, true) } assertTrue(viewModel.uiState.value is CourseHomeUIState.CourseData) val courseData = viewModel.uiState.value as CourseHomeUIState.CourseData @@ -208,7 +209,8 @@ class CourseHomeViewModelTest { coEvery { interactor.getCourseProgress( courseId, - false + false, + true ) } returns flow { emit(courseProgress) } @@ -255,7 +257,8 @@ class CourseHomeViewModelTest { coEvery { interactor.getCourseProgress( courseId, - false + false, + true ) } returns flow { emit(courseProgress) } @@ -303,7 +306,8 @@ class CourseHomeViewModelTest { coEvery { interactor.getCourseProgress( courseId, - false + false, + true ) } returns flow { emit(courseProgress) } @@ -351,7 +355,8 @@ class CourseHomeViewModelTest { coEvery { interactor.getCourseProgress( courseId, - false + false, + true ) } returns flow { emit(courseProgress) } @@ -407,7 +412,8 @@ class CourseHomeViewModelTest { coEvery { interactor.getCourseProgress( courseId, - false + false, + true ) } returns flow { emit(courseProgress) } @@ -462,7 +468,8 @@ class CourseHomeViewModelTest { coEvery { interactor.getCourseProgress( courseId, - false + false, + true ) } returns flow { emit(courseProgress) } @@ -519,7 +526,8 @@ class CourseHomeViewModelTest { coEvery { interactor.getCourseProgress( courseId, - false + false, + true ) } returns flow { emit(courseProgress) } @@ -576,7 +584,8 @@ class CourseHomeViewModelTest { coEvery { interactor.getCourseProgress( courseId, - false + false, + true ) } returns flow { emit(courseProgress) } @@ -631,7 +640,8 @@ class CourseHomeViewModelTest { coEvery { interactor.getCourseProgress( courseId, - false + false, + true ) } returns flow { emit(courseProgress) } @@ -659,7 +669,7 @@ class CourseHomeViewModelTest { viewModel.getCourseProgress() - coVerify { interactor.getCourseProgress(courseId, false) } + coVerify { interactor.getCourseProgress(courseId, false, true) } } @Test @@ -678,7 +688,8 @@ class CourseHomeViewModelTest { coEvery { interactor.getCourseProgress( courseId, - false + false, + true ) } returns flow { emit(courseProgress) } @@ -725,7 +736,8 @@ class CourseHomeViewModelTest { coEvery { interactor.getCourseProgress( courseId, - false + false, + true ) } returns flow { emit(courseProgress) } @@ -770,7 +782,8 @@ class CourseHomeViewModelTest { coEvery { interactor.getCourseProgress( courseId, - false + false, + true ) } returns flow { emit(courseProgress) } @@ -798,7 +811,7 @@ class CourseHomeViewModelTest { advanceUntilIdle() - coVerify(atLeast = 2) { interactor.getCourseProgress(courseId, false) } + coVerify(atLeast = 2) { interactor.getCourseProgress(courseId, false, true) } } @Test @@ -819,7 +832,8 @@ class CourseHomeViewModelTest { coEvery { interactor.getCourseProgress( courseId, - false + false, + true ) } returns flow { emit(courseProgress) } From 71cc7deac79fe0f8bb809095e8f8f6547733ec88 Mon Sep 17 00:00:00 2001 From: PavloNetrebchuk Date: Mon, 22 Sep 2025 15:19:08 +0300 Subject: [PATCH 16/17] feat: changes according PR feedback --- .../org.openedx.app.room.AppDatabase/4.json | 10 +-- .../data/model/room/VideoProgressEntity.kt | 4 +- .../core/domain/model/CourseProgress.kt | 31 ++++---- .../org/openedx/core/ui/theme/Colors.kt | 2 +- .../data/repository/CourseRepository.kt | 4 +- .../home/AssignmentsHomePagerCardContent.kt | 27 ++++--- .../CourseCompletionHomePagerCardContent.kt | 3 +- .../presentation/home/CourseHomeScreen.kt | 32 ++++++--- .../presentation/home/CourseHomeUIState.kt | 2 +- .../presentation/home/CourseHomeViewModel.kt | 12 +++- .../home/GradesHomePagerCardContent.kt | 22 +++--- .../home/VideosHomePagerCardContent.kt | 14 +++- .../progress/CourseProgressScreen.kt | 10 +-- .../progress/CourseProgressUIState.kt | 6 +- .../progress/CourseProgressViewModel.kt | 48 +++++++------ .../course/presentation/ui/CourseUI.kt | 70 +++++++++++-------- .../unit/video/EncodedVideoUnitViewModel.kt | 10 ++- .../videos/CourseContentVideoScreen.kt | 2 +- .../presentation/videos/CourseVideoUIState.kt | 2 +- .../videos/CourseVideoViewModel.kt | 9 ++- course/src/main/res/values/strings.xml | 7 +- 21 files changed, 197 insertions(+), 130 deletions(-) diff --git a/app/schemas/org.openedx.app.room.AppDatabase/4.json b/app/schemas/org.openedx.app.room.AppDatabase/4.json index 0f1e1c17b..0bf47775d 100644 --- a/app/schemas/org.openedx.app.room.AppDatabase/4.json +++ b/app/schemas/org.openedx.app.room.AppDatabase/4.json @@ -2,7 +2,7 @@ "formatVersion": 1, "database": { "version": 4, - "identityHash": "488bd2b78e977fef626afb28014c80f2", + "identityHash": "7ea446decde04c9c16700cb3981703c2", "entities": [ { "tableName": "course_discovery_table", @@ -1008,7 +1008,7 @@ }, { "tableName": "video_progress_table", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`block_id` TEXT NOT NULL, `video_url` TEXT NOT NULL, `video_time` INTEGER NOT NULL, `duration` INTEGER NOT NULL, PRIMARY KEY(`block_id`))", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`block_id` TEXT NOT NULL, `video_url` TEXT NOT NULL, `video_time` INTEGER, `duration` INTEGER, PRIMARY KEY(`block_id`))", "fields": [ { "fieldPath": "blockId", @@ -1026,13 +1026,13 @@ "fieldPath": "videoTime", "columnName": "video_time", "affinity": "INTEGER", - "notNull": true + "notNull": false }, { "fieldPath": "duration", "columnName": "duration", "affinity": "INTEGER", - "notNull": true + "notNull": false } ], "primaryKey": { @@ -1230,7 +1230,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, '488bd2b78e977fef626afb28014c80f2')" + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '7ea446decde04c9c16700cb3981703c2')" ] } } \ No newline at end of file diff --git a/core/src/main/java/org/openedx/core/data/model/room/VideoProgressEntity.kt b/core/src/main/java/org/openedx/core/data/model/room/VideoProgressEntity.kt index fbe2866e7..2ee58d802 100644 --- a/core/src/main/java/org/openedx/core/data/model/room/VideoProgressEntity.kt +++ b/core/src/main/java/org/openedx/core/data/model/room/VideoProgressEntity.kt @@ -12,7 +12,7 @@ data class VideoProgressEntity( @ColumnInfo("video_url") val videoUrl: String, @ColumnInfo("video_time") - val videoTime: Long, + val videoTime: Long?, @ColumnInfo("duration") - val duration: Long, + val duration: Long?, ) 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 0e3ceea48..77ae5f65a 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 @@ -28,24 +28,6 @@ data class CourseProgress( 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): Float { val assignmentSections = getAssignmentSections(type) if (assignmentSections.isEmpty()) return 0f @@ -71,13 +53,24 @@ data class CourseProgress( } fun getNotEmptyGradingPolicies() = gradingPolicy?.assignmentPolicies?.mapNotNull { - if (getPossibleAssignmentProblems(it) > 0) { + if (getAssignmentSections(it.type).isNotEmpty()) { it } else { null } } + fun getCompletedAssignmentCount( + policy: GradingPolicy.AssignmentPolicy, + courseStructure: CourseStructure? = null + ): Int { + val assignments = getAssignmentSections(policy.type) + return courseStructure?.blockData + ?.filter { it.id in assignments.map { assignment -> assignment.blockKey } } + ?.filter { it.isCompleted() } + ?.size ?: 0 + } + data class CertificateData( val certStatus: String, val certWebViewUrl: String, 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 28ed4ca95..089acc04f 100644 --- a/core/src/openedx/org/openedx/core/ui/theme/Colors.kt +++ b/core/src/openedx/org/openedx/core/ui/theme/Colors.kt @@ -75,7 +75,7 @@ val light_settings_title_content = Color.White val light_progress_bar_color = light_success_green val light_progress_bar_background_color = Color(0xFFCCD4E0) val light_grade_progress_bar_color = Color.Black -val light_grade_progress_bar_background = light_background +val light_grade_progress_bar_background = Color(0xFFCCD4E0) val light_assignment_card_border = Color(0xFFCCD4E0) val dark_primary = Color(0xFF3F68F8) 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 4225f278b..a6db3df5a 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 @@ -254,13 +254,13 @@ class CourseRepository( suspend fun getVideoProgress(blockId: String): VideoProgressEntity { return courseDao.getVideoProgressByBlockId(blockId) - ?: VideoProgressEntity(blockId, "", 0L, 0L) + ?: VideoProgressEntity(blockId, "", null, null) } fun getCourseProgress( courseId: String, isRefresh: Boolean, - getOnlyCacheIfExist: Boolean + getOnlyCacheIfExist: Boolean // If true, only returns cached data if available, otherwise fetches from network ): Flow = channelFlowWithAwait { var courseProgress: CourseProgressEntity? = null diff --git a/course/src/main/java/org/openedx/course/presentation/home/AssignmentsHomePagerCardContent.kt b/course/src/main/java/org/openedx/course/presentation/home/AssignmentsHomePagerCardContent.kt index 686dd2de1..3c0afb1e1 100644 --- a/course/src/main/java/org/openedx/course/presentation/home/AssignmentsHomePagerCardContent.kt +++ b/course/src/main/java/org/openedx/course/presentation/home/AssignmentsHomePagerCardContent.kt @@ -25,6 +25,7 @@ import androidx.compose.runtime.Composable 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.res.stringResource import androidx.compose.ui.semantics.semantics import androidx.compose.ui.text.font.FontWeight @@ -128,7 +129,8 @@ fun AssignmentsHomePagerCardContent( AssignmentCard( assignment = firstIncompleteAssignment, sectionName = getBlockParent(firstIncompleteAssignment.id)?.displayName ?: "", - onAssignmentClick = onAssignmentClick + onAssignmentClick = onAssignmentClick, + background = MaterialTheme.appColors.background, ) } else { CaughtUpMessage( @@ -150,7 +152,8 @@ fun AssignmentsHomePagerCardContent( private fun AssignmentCard( assignment: Block, sectionName: String, - onAssignmentClick: (Block) -> Unit + onAssignmentClick: (Block) -> Unit, + background: Color = MaterialTheme.appColors.surface ) { val isDuePast = assignment.due != null && assignment.due!! < Date() @@ -158,7 +161,7 @@ private fun AssignmentCard( val headerText = if (isDuePast) { stringResource(coreR.string.core_date_type_past_due) } else { - stringResource(R.string.course_due_soon) + stringResource(R.string.course_next_assignment) } // Due date status text @@ -199,7 +202,7 @@ private fun AssignmentCard( modifier = Modifier .fillMaxWidth() .clickable { onAssignmentClick(assignment) }, - backgroundColor = MaterialTheme.appColors.surface, + backgroundColor = background, border = BorderStroke(1.dp, MaterialTheme.appColors.cardViewBorder), shape = RoundedCornerShape(8.dp), elevation = 0.dp @@ -214,13 +217,15 @@ private fun AssignmentCard( Row( verticalAlignment = Alignment.CenterVertically ) { - Icon( - imageVector = Icons.Filled.Timer, - contentDescription = null, - modifier = Modifier.size(24.dp), - tint = MaterialTheme.appColors.warning - ) - Spacer(modifier = Modifier.width(8.dp)) + if (isDuePast) { + Icon( + imageVector = Icons.Filled.Timer, + contentDescription = null, + modifier = Modifier.size(24.dp), + tint = MaterialTheme.appColors.warning + ) + Spacer(modifier = Modifier.width(8.dp)) + } Text( text = headerText, style = MaterialTheme.appTypography.titleMedium, diff --git a/course/src/main/java/org/openedx/course/presentation/home/CourseCompletionHomePagerCardContent.kt b/course/src/main/java/org/openedx/course/presentation/home/CourseCompletionHomePagerCardContent.kt index 679d0f391..031a3a145 100644 --- a/course/src/main/java/org/openedx/course/presentation/home/CourseCompletionHomePagerCardContent.kt +++ b/course/src/main/java/org/openedx/course/presentation/home/CourseCompletionHomePagerCardContent.kt @@ -110,7 +110,8 @@ fun CourseCompletionHomePagerCardContent( downloadedStateMap = uiState.downloadedState, onSubSectionClick = onSubSectionClick, onDownloadClick = onDownloadClick, - progress = progress + progress = progress, + background = MaterialTheme.appColors.background ) } diff --git a/course/src/main/java/org/openedx/course/presentation/home/CourseHomeScreen.kt b/course/src/main/java/org/openedx/course/presentation/home/CourseHomeScreen.kt index b4adcc443..c0c6e1695 100644 --- a/course/src/main/java/org/openedx/course/presentation/home/CourseHomeScreen.kt +++ b/course/src/main/java/org/openedx/course/presentation/home/CourseHomeScreen.kt @@ -3,6 +3,7 @@ package org.openedx.course.presentation.home import android.content.res.Configuration.UI_MODE_NIGHT_NO import android.content.res.Configuration.UI_MODE_NIGHT_YES import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues @@ -41,6 +42,7 @@ import androidx.compose.ui.platform.AndroidUriHandler import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.tooling.preview.Devices import androidx.compose.ui.tooling.preview.Preview @@ -71,6 +73,7 @@ import org.openedx.foundation.presentation.UIMessage import org.openedx.foundation.presentation.WindowSize import org.openedx.foundation.presentation.WindowType import org.openedx.foundation.presentation.windowSizeValue +import org.openedx.core.R as coreR @Composable fun CourseHomeScreen( @@ -414,14 +417,27 @@ fun CaughtUpMessage( modifier: Modifier = Modifier, message: String, ) { - Text( - modifier = modifier - .fillMaxWidth(), - text = message, - color = MaterialTheme.appColors.textPrimary, - style = MaterialTheme.appTypography.bodyLarge, - textAlign = TextAlign.Center - ) + Column( + modifier = Modifier.fillMaxWidth(), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + Icon( + modifier = Modifier.size(48.dp), + painter = painterResource(coreR.drawable.core_ic_check), + contentDescription = null, + tint = MaterialTheme.appColors.successGreen + ) + Text( + modifier = modifier + .fillMaxWidth(), + text = message, + color = MaterialTheme.appColors.textPrimary, + style = MaterialTheme.appTypography.bodyLarge, + fontWeight = FontWeight.SemiBold, + textAlign = TextAlign.Center + ) + } } @Preview(uiMode = UI_MODE_NIGHT_NO) diff --git a/course/src/main/java/org/openedx/course/presentation/home/CourseHomeUIState.kt b/course/src/main/java/org/openedx/course/presentation/home/CourseHomeUIState.kt index 886db4a7b..773cb07df 100644 --- a/course/src/main/java/org/openedx/course/presentation/home/CourseHomeUIState.kt +++ b/course/src/main/java/org/openedx/course/presentation/home/CourseHomeUIState.kt @@ -22,7 +22,7 @@ sealed class CourseHomeUIState { val courseVideos: Map>, val courseAssignments: List, val videoPreview: VideoPreview?, - val videoProgress: Float, + val videoProgress: Float?, ) : CourseHomeUIState() data object Error : CourseHomeUIState() diff --git a/course/src/main/java/org/openedx/course/presentation/home/CourseHomeViewModel.kt b/course/src/main/java/org/openedx/course/presentation/home/CourseHomeViewModel.kt index 95ece8979..408512237 100644 --- a/course/src/main/java/org/openedx/course/presentation/home/CourseHomeViewModel.kt +++ b/course/src/main/java/org/openedx/course/presentation/home/CourseHomeViewModel.kt @@ -26,6 +26,7 @@ import org.openedx.core.domain.model.CourseStructure import org.openedx.core.extension.getChapterBlocks import org.openedx.core.extension.getSequentialBlocks import org.openedx.core.extension.getVerticalBlocks +import org.openedx.core.extension.safeDivBy import org.openedx.core.module.DownloadWorkerController import org.openedx.core.module.db.DownloadDao import org.openedx.core.module.download.BaseDownloadViewModel @@ -233,9 +234,14 @@ class CourseHomeViewModel( val videoProgress = if (firstIncompleteVideo != null) { try { val videoProgressEntity = interactor.getVideoProgress(firstIncompleteVideo.id) - val progress = - videoProgressEntity.videoTime.toFloat() / videoProgressEntity.duration.toFloat() - progress.coerceIn(0f, 1f) + val videoTime = videoProgressEntity.videoTime?.toFloat() + val videoDuration = videoProgressEntity.duration?.toFloat() + val progress = if (videoTime != null && videoDuration != null) { + videoTime.safeDivBy(videoDuration) + } else { + null + } + progress?.coerceIn(0f, 1f) } catch (_: Exception) { 0f } diff --git a/course/src/main/java/org/openedx/course/presentation/home/GradesHomePagerCardContent.kt b/course/src/main/java/org/openedx/course/presentation/home/GradesHomePagerCardContent.kt index a8129c1ab..962732203 100644 --- a/course/src/main/java/org/openedx/course/presentation/home/GradesHomePagerCardContent.kt +++ b/course/src/main/java/org/openedx/course/presentation/home/GradesHomePagerCardContent.kt @@ -27,6 +27,7 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import org.openedx.core.domain.model.CourseProgress +import org.openedx.core.domain.model.CourseStructure import org.openedx.core.ui.theme.appColors import org.openedx.core.ui.theme.appShapes import org.openedx.core.ui.theme.appTypography @@ -89,9 +90,10 @@ fun GradesHomePagerCardContent( } Spacer(modifier = Modifier.height(20.dp)) GradeCardsGrid( - assignmentPolicies = gradingPolicy.assignmentPolicies, + assignmentPolicies = assignmentPolicies, assignmentColors = gradingPolicy.assignmentColors, - progress = courseProgress + progress = courseProgress, + courseStructure = uiState.courseStructure ) Spacer(modifier = Modifier.height(8.dp)) ViewAllButton( @@ -105,11 +107,13 @@ fun GradesHomePagerCardContent( private fun GradeCard( policy: CourseProgress.GradingPolicy.AssignmentPolicy, progress: CourseProgress, + courseStructure: CourseStructure?, color: Color, modifier: Modifier = Modifier ) { - val earned = progress.getEarnedAssignmentProblems(policy) - val possible = progress.getPossibleAssignmentProblems(policy) + val assignments = progress.getAssignmentSections(policy.type) + val earned = progress.getCompletedAssignmentCount(policy, courseStructure) + val possible = assignments.size val gradePercent = if (possible > 0) (earned.toFloat() / possible * 100).toInt() else 0 Card( @@ -156,7 +160,7 @@ private fun GradeCard( Spacer(modifier = Modifier.width(8.dp)) Column { Text( - text = if (possible > 0) "$gradePercent%" else "--%", + text = "$gradePercent%", style = MaterialTheme.appTypography.bodyLarge, color = MaterialTheme.appColors.textPrimary, fontWeight = FontWeight.Bold @@ -165,8 +169,8 @@ private fun GradeCard( Text( text = stringResource( R.string.course_progress_earned_possible_assignment_problems, - earned.toInt(), - possible.toInt() + earned, + possible ), style = MaterialTheme.appTypography.labelSmall, color = MaterialTheme.appColors.textPrimary, @@ -181,7 +185,8 @@ private fun GradeCard( private fun GradeCardsGrid( assignmentPolicies: List, assignmentColors: List, - progress: CourseProgress + progress: CourseProgress, + courseStructure: CourseStructure? ) { Column( verticalArrangement = Arrangement.spacedBy(16.dp) @@ -198,6 +203,7 @@ private fun GradeCardsGrid( modifier = Modifier.weight(1f), policy = policy, progress = progress, + courseStructure = courseStructure, color = if (assignmentColors.isNotEmpty()) { assignmentColors[policyIndex % assignmentColors.size] } else { diff --git a/course/src/main/java/org/openedx/course/presentation/home/VideosHomePagerCardContent.kt b/course/src/main/java/org/openedx/course/presentation/home/VideosHomePagerCardContent.kt index 9a9e2c907..ecbeec99b 100644 --- a/course/src/main/java/org/openedx/course/presentation/home/VideosHomePagerCardContent.kt +++ b/course/src/main/java/org/openedx/course/presentation/home/VideosHomePagerCardContent.kt @@ -52,6 +52,11 @@ fun VideosHomePagerCardContent( val completedVideos = allVideos.count { it.isCompleted() } val totalVideos = allVideos.size val firstIncompleteVideo = allVideos.find { !it.isCompleted() } + val videoProgress = uiState.videoProgress ?: if (firstIncompleteVideo?.isCompleted() ?: false) { + 1f + } else { + 0f + } Column( modifier = Modifier @@ -111,8 +116,13 @@ fun VideosHomePagerCardContent( // Continue Watching section if (firstIncompleteVideo != null) { + val title = if (videoProgress > 0) { + stringResource(R.string.course_continue_watching) + } else { + stringResource(R.string.course_next_video) + } Text( - text = stringResource(R.string.course_continue_watching), + text = title, style = MaterialTheme.appTypography.titleMedium, color = MaterialTheme.appColors.textPrimary, fontWeight = FontWeight.SemiBold @@ -139,7 +149,7 @@ fun VideosHomePagerCardContent( .height(180.dp), videoBlock = firstIncompleteVideo, preview = uiState.videoPreview, - progress = uiState.videoProgress, + progress = videoProgress, onClick = { onVideoClick(firstIncompleteVideo) }, 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 66fa746df..c2954c84a 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 @@ -146,7 +146,7 @@ private fun CourseProgressContent( } itemsIndexed(assignmentPolicies) { index, policy -> AssignmentTypeRow( - progress = uiState.progress, + uiState = uiState, policy = policy, color = if (gradingPolicy.assignmentColors.isNotEmpty()) { gradingPolicy.assignmentColors[index % gradingPolicy.assignmentColors.size] @@ -351,12 +351,12 @@ private fun CourseCompletionView( @Composable private fun AssignmentTypeRow( - progress: CourseProgress, + uiState: CourseProgressUIState.Data, policy: CourseProgress.GradingPolicy.AssignmentPolicy, color: Color ) { - val assignments = progress.getAssignmentSections(policy.type) - val earned = assignments.filter { it.numPointsEarned > 0f }.size // Is it correct? + val assignments = uiState.progress.getAssignmentSections(policy.type) + val earned = uiState.progress.getCompletedAssignmentCount(policy, uiState.courseStructure) val possible = assignments.size Column( modifier = Modifier @@ -412,7 +412,7 @@ private fun AssignmentTypeRow( Text( stringResource( R.string.course_progress_current_and_max_weighted_graded_percent, - progress.getAssignmentWeightedGradedPercent(policy).toInt(), + uiState.progress.getAssignmentWeightedGradedPercent(policy).toInt(), (policy.weight * 100).toInt() ), style = MaterialTheme.appTypography.bodyLarge, 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 index 25771f631..ce504ce39 100644 --- a/course/src/main/java/org/openedx/course/presentation/progress/CourseProgressUIState.kt +++ b/course/src/main/java/org/openedx/course/presentation/progress/CourseProgressUIState.kt @@ -1,9 +1,13 @@ package org.openedx.course.presentation.progress import org.openedx.core.domain.model.CourseProgress +import org.openedx.core.domain.model.CourseStructure sealed class CourseProgressUIState { data object Error : CourseProgressUIState() data object Loading : CourseProgressUIState() - data class Data(val progress: CourseProgress) : CourseProgressUIState() + data class Data( + val progress: CourseProgress, + val courseStructure: CourseStructure?, + ) : 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 index 99bae2bcd..805f486d1 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,7 +1,6 @@ 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 @@ -9,7 +8,7 @@ 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.flow.combine import kotlinx.coroutines.launch import org.openedx.core.system.notifier.CourseLoading import org.openedx.core.system.notifier.CourseNotifier @@ -34,31 +33,34 @@ class CourseProgressViewModel( val uiMessage: SharedFlow get() = _uiMessage.asSharedFlow() - private var progressJob: Job? = null - init { - loadCourseProgress(false) + collectData(false) collectCourseNotifier() } - fun loadCourseProgress(isRefresh: Boolean) { - progressJob?.cancel() - progressJob = viewModelScope.launch { - if (!isRefresh) { - _uiState.value = CourseProgressUIState.Loading - } - interactor.getCourseProgress(courseId, isRefresh, getOnlyCacheIfExist = false) - .catch { e -> - 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)) - courseNotifier.send(CourseProgressLoaded) + private fun collectData(isRefresh: Boolean) { + viewModelScope.launch { + val courseProgressFlow = interactor.getCourseProgress(courseId, isRefresh, false) + val courseStructureFlow = interactor.getCourseStructureFlow(courseId) + + combine( + courseProgressFlow, + courseStructureFlow + ) { courseProgress, courseStructure -> + courseProgress to courseStructure + }.catch { e -> + if (_uiState.value !is CourseProgressUIState.Data) { + _uiState.value = CourseProgressUIState.Error } + courseNotifier.send(CourseLoading(false)) + }.collect { (courseProgress, courseStructure) -> + _uiState.value = CourseProgressUIState.Data( + courseProgress, + courseStructure + ) + courseNotifier.send(CourseLoading(false)) + courseNotifier.send(CourseProgressLoaded) + } } } @@ -66,7 +68,7 @@ class CourseProgressViewModel( viewModelScope.launch { courseNotifier.notifier.collect { event -> when (event) { - is RefreshProgress, is CourseStructureUpdated -> loadCourseProgress(true) + is RefreshProgress, is CourseStructureUpdated -> collectData(true) } } } diff --git a/course/src/main/java/org/openedx/course/presentation/ui/CourseUI.kt b/course/src/main/java/org/openedx/course/presentation/ui/CourseUI.kt index 1776c7286..82c28cb4f 100644 --- a/course/src/main/java/org/openedx/course/presentation/ui/CourseUI.kt +++ b/course/src/main/java/org/openedx/course/presentation/ui/CourseUI.kt @@ -618,7 +618,7 @@ fun CourseVideoSection( block: Block, videoBlocks: List, preview: Map, - progress: Map, + progress: Map, downloadedStateMap: Map, onVideoClick: (Block) -> Unit, onDownloadClick: (blocksIds: List) -> Unit, @@ -632,6 +632,8 @@ fun CourseVideoSection( filteredStatuses.any { it.isWaitingOrDownloading } -> DownloadedState.DOWNLOADING else -> DownloadedState.NOT_DOWNLOADED } + val videoCardWidth = 192.dp + val rowHorizontalArrangement = 8.dp LaunchedEffect(Unit) { try { @@ -655,23 +657,29 @@ fun CourseVideoSection( ) LazyRow( state = state, - horizontalArrangement = Arrangement.spacedBy(8.dp), + horizontalArrangement = Arrangement.spacedBy(rowHorizontalArrangement), contentPadding = PaddingValues( top = 8.dp, bottom = 16.dp, start = 16.dp, - end = 16.dp, + end = videoCardWidth + rowHorizontalArrangement, ) ) { items(videoBlocks) { block -> + val localProgress = progress[block.id] + val progress = localProgress ?: if (block.isCompleted()) { + 1f + } else { + 0f + } CourseVideoItem( modifier = Modifier - .width(192.dp) + .width(videoCardWidth) .height(108.dp) .clip(MaterialTheme.appShapes.videoPreviewShape), videoBlock = block, preview = preview[block.id], - progress = progress[block.id] ?: 0f, + progress = progress, onClick = { onVideoClick(block) } @@ -758,12 +766,13 @@ fun CourseVideoItem( ) // Progress bar (bottom) - if (progress > 0.0f) { - Box( - modifier = Modifier - .align(Alignment.BottomCenter), - contentAlignment = Alignment.Center - ) { + Box( + modifier = Modifier + .fillMaxWidth() + .align(Alignment.BottomCenter), + contentAlignment = Alignment.Center + ) { + if (progress > 0.0f) { LinearProgressIndicator( modifier = progressModifier .fillMaxWidth() @@ -776,23 +785,23 @@ fun CourseVideoItem( }, backgroundColor = MaterialTheme.appColors.progressBarBackgroundColor ) - if (videoBlock.isCompleted()) { - Image( - modifier = Modifier - .align(Alignment.BottomEnd) - .size(16.dp) - .offset(x = 1.dp), - painter = painterResource(id = coreR.drawable.ic_core_check), - contentDescription = stringResource(R.string.course_accessibility_video_watched), - ) - } else { - Box( - modifier = Modifier - .align(Alignment.BottomEnd) - .size(16.dp) - .offset(x = 1.dp), - ) - } + } + if (videoBlock.isCompleted()) { + Image( + modifier = Modifier + .align(Alignment.BottomEnd) + .size(16.dp) + .offset(x = 1.dp), + painter = painterResource(id = coreR.drawable.ic_core_check), + contentDescription = stringResource(R.string.course_accessibility_video_watched), + ) + } else { + Box( + modifier = Modifier + .align(Alignment.BottomEnd) + .size(16.dp) + .offset(x = 1.dp), + ) } } } @@ -925,7 +934,8 @@ fun CourseSection( downloadedStateMap: Map, onSubSectionClick: (Block) -> Unit, onDownloadClick: (blocksIds: List) -> Unit, - progress: Float? = null + progress: Float? = null, + background: Color = MaterialTheme.appColors.cardViewBackground ) { val arrowRotation by animateFloatAsState( targetValue = if (isSectionVisible == true) { @@ -953,7 +963,7 @@ fun CourseSection( modifier = modifier .clip(MaterialTheme.appShapes.sectionCardShape) .noRippleClickable { onItemClick(section) } - .background(MaterialTheme.appColors.cardViewBackground) + .background(background) .border( 1.dp, MaterialTheme.appColors.cardViewBorder, diff --git a/course/src/main/java/org/openedx/course/presentation/unit/video/EncodedVideoUnitViewModel.kt b/course/src/main/java/org/openedx/course/presentation/unit/video/EncodedVideoUnitViewModel.kt index 76ded08a9..2c2816bc9 100644 --- a/course/src/main/java/org/openedx/course/presentation/unit/video/EncodedVideoUnitViewModel.kt +++ b/course/src/main/java/org/openedx/course/presentation/unit/video/EncodedVideoUnitViewModel.kt @@ -5,6 +5,7 @@ import android.content.Context import androidx.lifecycle.LifecycleOwner import androidx.lifecycle.LiveData import androidx.lifecycle.MutableLiveData +import androidx.lifecycle.viewModelScope import androidx.media3.cast.CastPlayer import androidx.media3.common.PlaybackParameters import androidx.media3.common.Player @@ -19,6 +20,8 @@ import androidx.media3.exoplayer.trackselection.DefaultTrackSelector import androidx.media3.exoplayer.upstream.DefaultBandwidthMeter import androidx.media3.extractor.DefaultExtractorsFactory import com.google.android.gms.cast.framework.CastContext +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch import org.openedx.core.data.storage.CorePreferences import org.openedx.core.domain.model.VideoQuality import org.openedx.core.module.TranscriptManager @@ -69,8 +72,13 @@ class EncodedVideoUnitViewModel( private val exoPlayerListener = object : Player.Listener { override fun onRenderedFirstFrame() { - duration = exoPlayer?.duration ?: 0L super.onRenderedFirstFrame() + viewModelScope.launch { + while (exoPlayer?.duration == null || exoPlayer?.duration!! < 0f) { + delay(500) + } + duration = exoPlayer?.duration ?: 0L + } } override fun onPlayWhenReadyChanged(playWhenReady: Boolean, reason: Int) { diff --git a/course/src/main/java/org/openedx/course/presentation/videos/CourseContentVideoScreen.kt b/course/src/main/java/org/openedx/course/presentation/videos/CourseContentVideoScreen.kt index 6872b49c5..a72d7333a 100644 --- a/course/src/main/java/org/openedx/course/presentation/videos/CourseContentVideoScreen.kt +++ b/course/src/main/java/org/openedx/course/presentation/videos/CourseContentVideoScreen.kt @@ -182,7 +182,7 @@ private fun CourseVideosUI( null }, description = stringResource( - R.string.course_completed, + R.string.course_completed_of, progress.completed, progress.total ) diff --git a/course/src/main/java/org/openedx/course/presentation/videos/CourseVideoUIState.kt b/course/src/main/java/org/openedx/course/presentation/videos/CourseVideoUIState.kt index 61f1c9283..584814c15 100644 --- a/course/src/main/java/org/openedx/course/presentation/videos/CourseVideoUIState.kt +++ b/course/src/main/java/org/openedx/course/presentation/videos/CourseVideoUIState.kt @@ -15,7 +15,7 @@ sealed class CourseVideoUIState { val downloadModelsSize: DownloadModelsSize, val isCompletedSectionsShown: Boolean, val videoPreview: Map, - val videoProgress: Map, + val videoProgress: Map, ) : CourseVideoUIState() data object Empty : CourseVideoUIState() diff --git a/course/src/main/java/org/openedx/course/presentation/videos/CourseVideoViewModel.kt b/course/src/main/java/org/openedx/course/presentation/videos/CourseVideoViewModel.kt index fc3e9577e..5dffd7688 100644 --- a/course/src/main/java/org/openedx/course/presentation/videos/CourseVideoViewModel.kt +++ b/course/src/main/java/org/openedx/course/presentation/videos/CourseVideoViewModel.kt @@ -149,8 +149,13 @@ class CourseVideoViewModel( initDownloadModelsStatus() val videoProgress = courseVideos.values.flatten().associate { block -> val videoProgressEntity = interactor.getVideoProgress(block.id) - val progress = videoProgressEntity.videoTime.toFloat() - .safeDivBy(videoProgressEntity.duration.toFloat()) + val videoTime = videoProgressEntity.videoTime?.toFloat() + val videoDuration = videoProgressEntity.duration?.toFloat() + val progress = if (videoTime != null && videoDuration != null) { + videoTime.safeDivBy(videoDuration) + } else { + null + } block.id to progress } val isCompletedSectionsShown = diff --git a/course/src/main/res/values/strings.xml b/course/src/main/res/values/strings.xml index 519647e56..dd59fcf8f 100644 --- a/course/src/main/res/values/strings.xml +++ b/course/src/main/res/values/strings.xml @@ -92,6 +92,7 @@ Continue Watching + Next Video View All Videos %1$s left Videos\ncompleted @@ -99,7 +100,7 @@ Assignments\ncompleted View All Assignments - Due Soon + Next Assignment Due Today: %1$s %1$d Days Past Due: %2$s Due in %1$d Days: %2$s @@ -111,6 +112,6 @@ View Progress - You\'re all caught up. Take a breather and relax. - You\'re all caught up on assignments. Take a breather and relax. + You\'re all caught up on videos! + You\'re all caught up on assignments! From 762ffe2bba3cf645e5330f1828b722085bdae892 Mon Sep 17 00:00:00 2001 From: PavloNetrebchuk Date: Tue, 7 Oct 2025 15:01:35 +0300 Subject: [PATCH 17/17] feat: changes according PR feedback --- app/src/main/java/org/openedx/app/AppRouter.kt | 2 +- .../main/java/org/openedx/app/deeplink/DeepLinkRouter.kt | 2 +- .../openedx/core/presentation/course/CourseViewMode.kt | 6 ------ core/src/main/java/org/openedx/core/utils/TimeUtils.kt | 3 ++- .../java/org/openedx/course/presentation/CourseRouter.kt | 2 +- .../assignments/CourseContentAssignmentScreen.kt | 2 +- .../course/presentation/dates/CourseDatesScreen.kt | 9 +++++++-- .../openedx/course/presentation/home/CourseHomeScreen.kt | 2 +- .../course/presentation/home/CourseHomeViewModel.kt | 4 ++-- .../presentation/outline/CourseContentAllScreen.kt | 2 +- .../presentation/outline/CourseContentAllViewModel.kt | 2 +- .../course/presentation/section/CourseSectionFragment.kt | 2 +- .../presentation/section/CourseSectionViewModel.kt | 2 +- .../unit/container/CourseUnitContainerFragment.kt | 1 - .../unit/container/CourseUnitContainerViewModel.kt | 1 - .../course/presentation/unit/container/CourseViewMode.kt | 6 ++++++ .../presentation/videos/CourseContentVideoScreen.kt | 2 +- .../course/presentation/home/CourseHomeViewModelTest.kt | 3 ++- .../presentation/section/CourseSectionViewModelTest.kt | 2 +- .../unit/container/CourseUnitContainerViewModelTest.kt | 1 - 20 files changed, 30 insertions(+), 26 deletions(-) delete mode 100644 core/src/main/java/org/openedx/core/presentation/course/CourseViewMode.kt create mode 100644 course/src/main/java/org/openedx/course/presentation/unit/container/CourseViewMode.kt diff --git a/app/src/main/java/org/openedx/app/AppRouter.kt b/app/src/main/java/org/openedx/app/AppRouter.kt index cfe1ecc44..4678344ee 100644 --- a/app/src/main/java/org/openedx/app/AppRouter.kt +++ b/app/src/main/java/org/openedx/app/AppRouter.kt @@ -11,7 +11,6 @@ import org.openedx.auth.presentation.signin.SignInFragment import org.openedx.auth.presentation.signup.SignUpFragment import org.openedx.core.CalendarRouter import org.openedx.core.FragmentViewType -import org.openedx.core.presentation.course.CourseViewMode import org.openedx.core.presentation.global.appupgrade.AppUpgradeRouter import org.openedx.core.presentation.global.appupgrade.UpgradeRequiredFragment import org.openedx.core.presentation.global.webview.WebContentFragment @@ -24,6 +23,7 @@ import org.openedx.course.presentation.handouts.HandoutsType import org.openedx.course.presentation.handouts.HandoutsWebViewFragment import org.openedx.course.presentation.section.CourseSectionFragment import org.openedx.course.presentation.unit.container.CourseUnitContainerFragment +import org.openedx.course.presentation.unit.container.CourseViewMode import org.openedx.course.presentation.unit.video.VideoFullScreenFragment import org.openedx.course.presentation.unit.video.YoutubeVideoFullScreenFragment import org.openedx.course.settings.download.DownloadQueueFragment diff --git a/app/src/main/java/org/openedx/app/deeplink/DeepLinkRouter.kt b/app/src/main/java/org/openedx/app/deeplink/DeepLinkRouter.kt index 6061eb6b1..2192a6b89 100644 --- a/app/src/main/java/org/openedx/app/deeplink/DeepLinkRouter.kt +++ b/app/src/main/java/org/openedx/app/deeplink/DeepLinkRouter.kt @@ -11,9 +11,9 @@ import org.openedx.auth.presentation.signin.SignInFragment import org.openedx.core.FragmentViewType import org.openedx.core.config.Config import org.openedx.core.data.storage.CorePreferences -import org.openedx.core.presentation.course.CourseViewMode import org.openedx.course.domain.interactor.CourseInteractor import org.openedx.course.presentation.handouts.HandoutsType +import org.openedx.course.presentation.unit.container.CourseViewMode import org.openedx.discovery.domain.interactor.DiscoveryInteractor import org.openedx.discovery.domain.model.Course import org.openedx.discovery.presentation.catalog.WebViewLink diff --git a/core/src/main/java/org/openedx/core/presentation/course/CourseViewMode.kt b/core/src/main/java/org/openedx/core/presentation/course/CourseViewMode.kt deleted file mode 100644 index 8a73475ed..000000000 --- a/core/src/main/java/org/openedx/core/presentation/course/CourseViewMode.kt +++ /dev/null @@ -1,6 +0,0 @@ -package org.openedx.core.presentation.course - -enum class CourseViewMode { - FULL, - VIDEOS -} diff --git a/core/src/main/java/org/openedx/core/utils/TimeUtils.kt b/core/src/main/java/org/openedx/core/utils/TimeUtils.kt index 9ab3ba354..cba6a5e8e 100644 --- a/core/src/main/java/org/openedx/core/utils/TimeUtils.kt +++ b/core/src/main/java/org/openedx/core/utils/TimeUtils.kt @@ -20,6 +20,7 @@ object TimeUtils { private const val FORMAT_ISO_8601 = "yyyy-MM-dd'T'HH:mm:ss'Z'" private const val FORMAT_ISO_8601_WITH_TIME_ZONE = "yyyy-MM-dd'T'HH:mm:ssXXX" + private const val FORMAT_MONTH_DAY = "MMM dd" private const val SEVEN_DAYS_IN_MILLIS = 604800000L fun formatToString(context: Context, date: Date, useRelativeDates: Boolean): String { @@ -98,7 +99,7 @@ object TimeUtils { } fun formatToMonthDay(date: Date): String { - val sdf = SimpleDateFormat("MMM dd", Locale.getDefault()) + val sdf = SimpleDateFormat(FORMAT_MONTH_DAY, Locale.getDefault()) return sdf.format(date) } diff --git a/course/src/main/java/org/openedx/course/presentation/CourseRouter.kt b/course/src/main/java/org/openedx/course/presentation/CourseRouter.kt index d600b0897..e09f2ad91 100644 --- a/course/src/main/java/org/openedx/course/presentation/CourseRouter.kt +++ b/course/src/main/java/org/openedx/course/presentation/CourseRouter.kt @@ -1,8 +1,8 @@ package org.openedx.course.presentation import androidx.fragment.app.FragmentManager -import org.openedx.core.presentation.course.CourseViewMode import org.openedx.course.presentation.handouts.HandoutsType +import org.openedx.course.presentation.unit.container.CourseViewMode interface CourseRouter { diff --git a/course/src/main/java/org/openedx/course/presentation/assignments/CourseContentAssignmentScreen.kt b/course/src/main/java/org/openedx/course/presentation/assignments/CourseContentAssignmentScreen.kt index d1f8df535..ae238e84e 100644 --- a/course/src/main/java/org/openedx/course/presentation/assignments/CourseContentAssignmentScreen.kt +++ b/course/src/main/java/org/openedx/course/presentation/assignments/CourseContentAssignmentScreen.kt @@ -58,7 +58,6 @@ import org.openedx.core.domain.model.Block import org.openedx.core.domain.model.BlockCounts import org.openedx.core.domain.model.CourseProgress import org.openedx.core.domain.model.Progress -import org.openedx.core.presentation.course.CourseViewMode import org.openedx.core.ui.theme.OpenEdXTheme import org.openedx.core.ui.theme.appColors import org.openedx.core.ui.theme.appShapes @@ -67,6 +66,7 @@ import org.openedx.core.utils.TimeUtils import org.openedx.course.R import org.openedx.course.presentation.contenttab.CourseContentAssignmentEmptyState import org.openedx.course.presentation.ui.CourseProgress +import org.openedx.course.presentation.unit.container.CourseViewMode import org.openedx.foundation.presentation.WindowSize import org.openedx.foundation.presentation.WindowType import org.openedx.foundation.presentation.windowSizeValue diff --git a/course/src/main/java/org/openedx/course/presentation/dates/CourseDatesScreen.kt b/course/src/main/java/org/openedx/course/presentation/dates/CourseDatesScreen.kt index 45417ab8f..326ff8839 100644 --- a/course/src/main/java/org/openedx/course/presentation/dates/CourseDatesScreen.kt +++ b/course/src/main/java/org/openedx/course/presentation/dates/CourseDatesScreen.kt @@ -67,7 +67,6 @@ import org.openedx.core.domain.model.CourseDatesBannerInfo import org.openedx.core.domain.model.CourseDatesResult import org.openedx.core.domain.model.DatesSection import org.openedx.core.presentation.CoreAnalyticsScreen -import org.openedx.core.presentation.course.CourseViewMode import org.openedx.core.presentation.dialog.alert.ActionDialogFragment import org.openedx.core.presentation.settings.calendarsync.CalendarSyncState import org.openedx.core.ui.CircularProgress @@ -82,6 +81,7 @@ import org.openedx.core.utils.TimeUtils.formatToString import org.openedx.core.utils.clearTime import org.openedx.course.presentation.ui.CourseDatesBanner import org.openedx.course.presentation.ui.CourseDatesBannerTablet +import org.openedx.course.presentation.unit.container.CourseViewMode import org.openedx.foundation.extension.isNotEmptyThenLet import org.openedx.foundation.presentation.UIMessage import org.openedx.foundation.presentation.WindowSize @@ -286,7 +286,12 @@ private fun CourseDatesUI( Row( modifier = Modifier .fillMaxWidth() - .padding(top = 8.dp, start = 16.dp, end = 8.dp, bottom = 8.dp), + .padding( + top = 8.dp, + start = 16.dp, + end = 8.dp, + bottom = 8.dp + ), horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically ) { diff --git a/course/src/main/java/org/openedx/course/presentation/home/CourseHomeScreen.kt b/course/src/main/java/org/openedx/course/presentation/home/CourseHomeScreen.kt index c0c6e1695..241d51f31 100644 --- a/course/src/main/java/org/openedx/course/presentation/home/CourseHomeScreen.kt +++ b/course/src/main/java/org/openedx/course/presentation/home/CourseHomeScreen.kt @@ -53,7 +53,6 @@ import org.openedx.core.Mock import org.openedx.core.NoContentScreenType import org.openedx.core.domain.model.Block import org.openedx.core.domain.model.CourseDatesBannerInfo -import org.openedx.core.presentation.course.CourseViewMode import org.openedx.core.ui.CircularProgress import org.openedx.core.ui.HandleUIMessage import org.openedx.core.ui.NoContentScreen @@ -68,6 +67,7 @@ import org.openedx.course.presentation.ui.CourseDatesBanner import org.openedx.course.presentation.ui.CourseDatesBannerTablet import org.openedx.course.presentation.ui.CourseMessage import org.openedx.course.presentation.ui.ResumeCourseButton +import org.openedx.course.presentation.unit.container.CourseViewMode import org.openedx.foundation.extension.takeIfNotEmpty import org.openedx.foundation.presentation.UIMessage import org.openedx.foundation.presentation.WindowSize diff --git a/course/src/main/java/org/openedx/course/presentation/home/CourseHomeViewModel.kt b/course/src/main/java/org/openedx/course/presentation/home/CourseHomeViewModel.kt index 408512237..a5f42db5c 100644 --- a/course/src/main/java/org/openedx/course/presentation/home/CourseHomeViewModel.kt +++ b/course/src/main/java/org/openedx/course/presentation/home/CourseHomeViewModel.kt @@ -32,7 +32,6 @@ import org.openedx.core.module.db.DownloadDao import org.openedx.core.module.download.BaseDownloadViewModel import org.openedx.core.module.download.DownloadHelper import org.openedx.core.presentation.CoreAnalytics -import org.openedx.core.presentation.course.CourseViewMode import org.openedx.core.presentation.dialog.downloaddialog.DownloadDialogManager import org.openedx.core.system.connection.NetworkConnection import org.openedx.core.system.notifier.CourseDatesShifted @@ -45,6 +44,7 @@ import org.openedx.course.presentation.CourseAnalytics import org.openedx.course.presentation.CourseAnalyticsEvent import org.openedx.course.presentation.CourseAnalyticsKey import org.openedx.course.presentation.CourseRouter +import org.openedx.course.presentation.unit.container.CourseViewMode import org.openedx.foundation.extension.isInternetError import org.openedx.foundation.presentation.UIMessage import org.openedx.foundation.system.ResourceManager @@ -137,7 +137,7 @@ class CourseHomeViewModel( useRelativeDates = preferencesManager.isRelativeDatesEnabled, next = state.next, courseProgress = state.courseProgress, - courseVideos = state.courseVideos, + courseVideos = courseVideos, courseAssignments = courseAssignments, videoPreview = state.videoPreview, videoProgress = state.videoProgress diff --git a/course/src/main/java/org/openedx/course/presentation/outline/CourseContentAllScreen.kt b/course/src/main/java/org/openedx/course/presentation/outline/CourseContentAllScreen.kt index 4ccffd99e..e8355387b 100644 --- a/course/src/main/java/org/openedx/course/presentation/outline/CourseContentAllScreen.kt +++ b/course/src/main/java/org/openedx/course/presentation/outline/CourseContentAllScreen.kt @@ -41,7 +41,6 @@ import org.openedx.core.domain.model.Block import org.openedx.core.domain.model.CourseDatesBannerInfo import org.openedx.core.domain.model.Progress import org.openedx.core.extension.getChapterBlocks -import org.openedx.core.presentation.course.CourseViewMode import org.openedx.core.ui.CircularProgress import org.openedx.core.ui.HandleUIMessage import org.openedx.core.ui.displayCutoutForLandscape @@ -55,6 +54,7 @@ import org.openedx.course.presentation.ui.CourseMessage import org.openedx.course.presentation.ui.CourseProgress import org.openedx.course.presentation.ui.CourseSection import org.openedx.course.presentation.ui.ResumeCourseButton +import org.openedx.course.presentation.unit.container.CourseViewMode import org.openedx.foundation.extension.takeIfNotEmpty import org.openedx.foundation.presentation.UIMessage import org.openedx.foundation.presentation.WindowSize diff --git a/course/src/main/java/org/openedx/course/presentation/outline/CourseContentAllViewModel.kt b/course/src/main/java/org/openedx/course/presentation/outline/CourseContentAllViewModel.kt index 2c966a0cf..d373467a0 100644 --- a/course/src/main/java/org/openedx/course/presentation/outline/CourseContentAllViewModel.kt +++ b/course/src/main/java/org/openedx/course/presentation/outline/CourseContentAllViewModel.kt @@ -28,7 +28,6 @@ import org.openedx.core.module.db.DownloadDao import org.openedx.core.module.download.BaseDownloadViewModel import org.openedx.core.module.download.DownloadHelper import org.openedx.core.presentation.CoreAnalytics -import org.openedx.core.presentation.course.CourseViewMode import org.openedx.core.presentation.dialog.downloaddialog.DownloadDialogManager import org.openedx.core.presentation.settings.calendarsync.CalendarSyncDialogType import org.openedx.core.system.connection.NetworkConnection @@ -42,6 +41,7 @@ import org.openedx.course.presentation.CourseAnalytics import org.openedx.course.presentation.CourseAnalyticsEvent import org.openedx.course.presentation.CourseAnalyticsKey import org.openedx.course.presentation.CourseRouter +import org.openedx.course.presentation.unit.container.CourseViewMode import org.openedx.foundation.extension.isInternetError import org.openedx.foundation.presentation.UIMessage import org.openedx.foundation.system.ResourceManager diff --git a/course/src/main/java/org/openedx/course/presentation/section/CourseSectionFragment.kt b/course/src/main/java/org/openedx/course/presentation/section/CourseSectionFragment.kt index 0fb24ebd6..36e20ce2c 100644 --- a/course/src/main/java/org/openedx/course/presentation/section/CourseSectionFragment.kt +++ b/course/src/main/java/org/openedx/course/presentation/section/CourseSectionFragment.kt @@ -58,7 +58,6 @@ import org.openedx.core.BlockType import org.openedx.core.domain.model.AssignmentProgress import org.openedx.core.domain.model.Block import org.openedx.core.domain.model.BlockCounts -import org.openedx.core.presentation.course.CourseViewMode import org.openedx.core.ui.BackBtn import org.openedx.core.ui.HandleUIMessage import org.openedx.core.ui.displayCutoutForLandscape @@ -70,6 +69,7 @@ import org.openedx.core.ui.theme.appTypography import org.openedx.course.R import org.openedx.course.presentation.CourseRouter import org.openedx.course.presentation.ui.CardArrow +import org.openedx.course.presentation.unit.container.CourseViewMode import org.openedx.foundation.extension.serializable import org.openedx.foundation.presentation.UIMessage import org.openedx.foundation.presentation.WindowSize diff --git a/course/src/main/java/org/openedx/course/presentation/section/CourseSectionViewModel.kt b/course/src/main/java/org/openedx/course/presentation/section/CourseSectionViewModel.kt index 2ebe2c9b3..8966ee45e 100644 --- a/course/src/main/java/org/openedx/course/presentation/section/CourseSectionViewModel.kt +++ b/course/src/main/java/org/openedx/course/presentation/section/CourseSectionViewModel.kt @@ -8,13 +8,13 @@ import kotlinx.coroutines.launch import org.openedx.core.BlockType import org.openedx.core.R import org.openedx.core.domain.model.Block -import org.openedx.core.presentation.course.CourseViewMode import org.openedx.core.system.notifier.CourseNotifier import org.openedx.core.system.notifier.CourseSectionChanged import org.openedx.course.domain.interactor.CourseInteractor import org.openedx.course.presentation.CourseAnalytics import org.openedx.course.presentation.CourseAnalyticsEvent import org.openedx.course.presentation.CourseAnalyticsKey +import org.openedx.course.presentation.unit.container.CourseViewMode import org.openedx.foundation.extension.isInternetError import org.openedx.foundation.presentation.BaseViewModel import org.openedx.foundation.presentation.SingleEventLiveData diff --git a/course/src/main/java/org/openedx/course/presentation/unit/container/CourseUnitContainerFragment.kt b/course/src/main/java/org/openedx/course/presentation/unit/container/CourseUnitContainerFragment.kt index c8ea5de29..176d92fb7 100644 --- a/course/src/main/java/org/openedx/course/presentation/unit/container/CourseUnitContainerFragment.kt +++ b/course/src/main/java/org/openedx/course/presentation/unit/container/CourseUnitContainerFragment.kt @@ -31,7 +31,6 @@ import org.koin.android.ext.android.inject import org.koin.androidx.viewmodel.ext.android.viewModel import org.koin.core.parameter.parametersOf import org.openedx.core.BlockType -import org.openedx.core.presentation.course.CourseViewMode import org.openedx.core.presentation.global.InsetHolder import org.openedx.core.ui.theme.OpenEdXTheme import org.openedx.core.ui.theme.appColors diff --git a/course/src/main/java/org/openedx/course/presentation/unit/container/CourseUnitContainerViewModel.kt b/course/src/main/java/org/openedx/course/presentation/unit/container/CourseUnitContainerViewModel.kt index 353a1b0ff..596102dd9 100644 --- a/course/src/main/java/org/openedx/course/presentation/unit/container/CourseUnitContainerViewModel.kt +++ b/course/src/main/java/org/openedx/course/presentation/unit/container/CourseUnitContainerViewModel.kt @@ -14,7 +14,6 @@ import org.openedx.core.config.Config import org.openedx.core.domain.model.Block import org.openedx.core.module.db.DownloadModel import org.openedx.core.module.db.DownloadedState -import org.openedx.core.presentation.course.CourseViewMode import org.openedx.core.system.connection.NetworkConnection import org.openedx.core.system.notifier.CourseNotifier import org.openedx.core.system.notifier.CourseSectionChanged diff --git a/course/src/main/java/org/openedx/course/presentation/unit/container/CourseViewMode.kt b/course/src/main/java/org/openedx/course/presentation/unit/container/CourseViewMode.kt new file mode 100644 index 000000000..1fcace78b --- /dev/null +++ b/course/src/main/java/org/openedx/course/presentation/unit/container/CourseViewMode.kt @@ -0,0 +1,6 @@ +package org.openedx.course.presentation.unit.container + +enum class CourseViewMode { + FULL, + VIDEOS +} diff --git a/course/src/main/java/org/openedx/course/presentation/videos/CourseContentVideoScreen.kt b/course/src/main/java/org/openedx/course/presentation/videos/CourseContentVideoScreen.kt index a72d7333a..f482596ec 100644 --- a/course/src/main/java/org/openedx/course/presentation/videos/CourseContentVideoScreen.kt +++ b/course/src/main/java/org/openedx/course/presentation/videos/CourseContentVideoScreen.kt @@ -37,7 +37,6 @@ import org.openedx.core.domain.model.CourseStructure import org.openedx.core.domain.model.CoursewareAccess import org.openedx.core.domain.model.Progress import org.openedx.core.module.download.DownloadModelsSize -import org.openedx.core.presentation.course.CourseViewMode import org.openedx.core.ui.CircularProgress import org.openedx.core.ui.HandleUIMessage import org.openedx.core.ui.displayCutoutForLandscape @@ -47,6 +46,7 @@ import org.openedx.course.R import org.openedx.course.presentation.contenttab.CourseContentVideoEmptyState import org.openedx.course.presentation.ui.CourseProgress import org.openedx.course.presentation.ui.CourseVideoSection +import org.openedx.course.presentation.unit.container.CourseViewMode import org.openedx.foundation.presentation.UIMessage import org.openedx.foundation.presentation.WindowSize import org.openedx.foundation.presentation.WindowType diff --git a/course/src/test/java/org/openedx/course/presentation/home/CourseHomeViewModelTest.kt b/course/src/test/java/org/openedx/course/presentation/home/CourseHomeViewModelTest.kt index 72e51c4d3..cbd0d04f0 100644 --- a/course/src/test/java/org/openedx/course/presentation/home/CourseHomeViewModelTest.kt +++ b/course/src/test/java/org/openedx/course/presentation/home/CourseHomeViewModelTest.kt @@ -45,6 +45,7 @@ import org.openedx.course.presentation.CourseRouter import org.openedx.foundation.system.ResourceManager import org.openedx.foundation.utils.FileUtil import java.net.UnknownHostException +import org.openedx.course.R as courseR @Suppress("LargeClass") @OptIn(ExperimentalCoroutinesApi::class) @@ -92,7 +93,7 @@ class CourseHomeViewModelTest { every { resourceManager.getString(R.string.core_error_no_connection) } returns noInternet every { resourceManager.getString(R.string.core_error_unknown_error) } returns somethingWrong every { - resourceManager.getString(org.openedx.course.R.string.course_can_download_only_with_wifi) + resourceManager.getString(courseR.string.course_can_download_only_with_wifi) } returns cantDownload every { resourceManager.getString(R.string.core_dates_shift_dates_unsuccessful_msg) diff --git a/course/src/test/java/org/openedx/course/presentation/section/CourseSectionViewModelTest.kt b/course/src/test/java/org/openedx/course/presentation/section/CourseSectionViewModelTest.kt index 7336e9307..685311e9e 100644 --- a/course/src/test/java/org/openedx/course/presentation/section/CourseSectionViewModelTest.kt +++ b/course/src/test/java/org/openedx/course/presentation/section/CourseSectionViewModelTest.kt @@ -38,11 +38,11 @@ import org.openedx.core.module.db.DownloadModelEntity import org.openedx.core.module.db.DownloadedState import org.openedx.core.module.db.FileType import org.openedx.core.presentation.CoreAnalytics -import org.openedx.core.presentation.course.CourseViewMode import org.openedx.core.system.connection.NetworkConnection import org.openedx.core.system.notifier.CourseNotifier import org.openedx.course.domain.interactor.CourseInteractor import org.openedx.course.presentation.CourseAnalytics +import org.openedx.course.presentation.unit.container.CourseViewMode import org.openedx.foundation.presentation.UIMessage import org.openedx.foundation.system.ResourceManager import java.net.UnknownHostException diff --git a/course/src/test/java/org/openedx/course/presentation/unit/container/CourseUnitContainerViewModelTest.kt b/course/src/test/java/org/openedx/course/presentation/unit/container/CourseUnitContainerViewModelTest.kt index becf35187..909fe0e8f 100644 --- a/course/src/test/java/org/openedx/course/presentation/unit/container/CourseUnitContainerViewModelTest.kt +++ b/course/src/test/java/org/openedx/course/presentation/unit/container/CourseUnitContainerViewModelTest.kt @@ -25,7 +25,6 @@ import org.openedx.core.domain.model.Block import org.openedx.core.domain.model.BlockCounts import org.openedx.core.domain.model.CourseStructure import org.openedx.core.domain.model.CoursewareAccess -import org.openedx.core.presentation.course.CourseViewMode import org.openedx.core.system.connection.NetworkConnection import org.openedx.core.system.notifier.CourseNotifier import org.openedx.course.domain.interactor.CourseInteractor