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/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/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/app/src/main/java/org/openedx/app/di/ScreenModule.kt b/app/src/main/java/org/openedx/app/di/ScreenModule.kt index 5d8f1eb5a..07caf8037 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,27 @@ 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(), + get() + ) + } viewModel { (courseId: String) -> CourseSectionViewModel( courseId, 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..445fc7a05 --- /dev/null +++ b/core/src/main/java/org/openedx/core/Mock.kt @@ -0,0 +1,263 @@ +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 { + 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), + ) + + 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/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 537959ece..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,32 +28,16 @@ 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 = 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() } @@ -68,6 +52,25 @@ data class CourseProgress( return if (notCompletedPercent < 0.0) 0f else notCompletedPercent.toFloat() } + fun getNotEmptyGradingPolicies() = gradingPolicy?.assignmentPolicies?.mapNotNull { + 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/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/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/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/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/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/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/core/src/main/java/org/openedx/core/utils/TimeUtils.kt b/core/src/main/java/org/openedx/core/utils/TimeUtils.kt index 572d4bc5c..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 { @@ -97,6 +98,11 @@ object TimeUtils { } } + fun formatToMonthDay(date: Date): String { + val sdf = SimpleDateFormat(FORMAT_MONTH_DAY, Locale.getDefault()) + return sdf.format(date) + } + fun getCurrentTime(): Long { return Calendar.getInstance().timeInMillis } 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/core/src/openedx/org/openedx/core/ui/theme/Colors.kt b/core/src/openedx/org/openedx/core/ui/theme/Colors.kt index df4f6c357..089acc04f 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 = Color(0xFFCCD4E0) +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/data/repository/CourseRepository.kt b/course/src/main/java/org/openedx/course/data/repository/CourseRepository.kt index 2e460bfa6..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 @@ -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 @@ -253,19 +254,26 @@ 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): Flow = + fun getCourseProgress( + courseId: String, + isRefresh: Boolean, + getOnlyCacheIfExist: Boolean // If true, only returns cached data if available, otherwise fetches from network + ): 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()) } } - val response = api.getCourseProgress(courseId) - courseDao.insertCourseProgressEntity(response.mapToRoomEntity(courseId)) - trySend(response.mapToDomain()) + 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/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/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/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 57d2d5766..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 @@ -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 @@ -56,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 @@ -65,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 @@ -132,6 +134,7 @@ private fun CourseContentAssignmentScreen( is CourseAssignmentUIState.Empty -> { CourseContentAssignmentEmptyState( + modifier = Modifier.verticalScroll(rememberScrollState()), onReturnToCourseClick = onNavigateToHome ) } @@ -145,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 ) @@ -265,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) ) { @@ -274,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 } ) } @@ -300,7 +299,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 @@ -426,7 +425,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 { @@ -492,12 +491,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 6280cd2fb..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 @@ -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,8 @@ 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 import org.openedx.course.presentation.ui.DatesShiftedSnackBar @@ -272,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() } @@ -292,28 +305,14 @@ fun CourseDashboard( scaffoldState = scaffoldState, backgroundColor = MaterialTheme.appColors.background, bottomBar = { + val currentPage = CourseContainerTab.entries[pagerState.currentPage] Box { - if (CourseContainerTab.entries[pagerState.currentPage] == CourseContainerTab.CONTENT && + if (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 - ) - } - } + AssignmentsBottomBar(scope = scope, pagerState = pagerState) + } else if (currentPage == CourseContainerTab.HOME) { + HomeNavigationRow(homePagerState = homePagerState) } var isInternetConnectionShown by rememberSaveable { mutableStateOf(false) @@ -411,6 +410,7 @@ fun CourseDashboard( viewModel = viewModel, pagerState = pagerState, contentTabPagerState = contentTabPagerState, + homePagerState = homePagerState, isResumed = isResumed, fragmentManager = fragmentManager, onContentTabSelected = { tab -> @@ -456,6 +456,7 @@ private fun DashboardPager( viewModel: CourseContainerViewModel, pagerState: PagerState, contentTabPagerState: PagerState, + homePagerState: PagerState, isResumed: Boolean, fragmentManager: FragmentManager, onContentTabSelected: (CourseContentTab) -> Unit, @@ -469,7 +470,36 @@ 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, + homePagerState = homePagerState, + onResetDatesClick = { + viewModel.onRefresh(CourseContainerTab.DATES) + }, + onNavigateToContent = { contentTab -> + scope.launch { + // First scroll to CONTENT tab + pagerState.scrollToPage( + CourseContainerTab.entries.indexOf(CourseContainerTab.CONTENT) + ) + // Then scroll to the specified content tab + contentTabPagerState.scrollToPage( + CourseContentTab.entries.indexOf(contentTab) + ) + } + }, + onNavigateToProgress = { + scope.launch { + pagerState.scrollToPage( + CourseContainerTab.entries.indexOf(CourseContainerTab.PROGRESS) + ) + } + } + ) } CourseContainerTab.DATES -> { @@ -552,7 +582,7 @@ private fun DashboardPager( onTabSelected = onContentTabSelected, onNavigateToHome = { scope.launch { - pagerState.animateScrollToPage( + pagerState.scrollToPage( CourseContainerTab.entries.indexOf( CourseContainerTab.HOME ) @@ -681,13 +711,101 @@ 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)) + } +} + +@Composable +private fun HomeNavigationRow(homePagerState: PagerState) { + val homeCoroutineScope = rememberCoroutineScope() + Row( + modifier = Modifier + .fillMaxWidth() + .background(MaterialTheme.appColors.background), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + val isPreviousPageEnabled = homePagerState.currentPage > 0 + IconButton( + modifier = Modifier.size(60.dp), + 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( + modifier = Modifier.size(60.dp), + 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/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/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/AssignmentsHomePagerCardContent.kt b/course/src/main/java/org/openedx/course/presentation/home/AssignmentsHomePagerCardContent.kt new file mode 100644 index 000000000..3c0afb1e1 --- /dev/null +++ b/course/src/main/java/org/openedx/course/presentation/home/AssignmentsHomePagerCardContent.kt @@ -0,0 +1,281 @@ +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.icons.Icons +import androidx.compose.material.icons.automirrored.filled.Assignment +import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight +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.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.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 org.openedx.course.presentation.contenttab.CourseContentAssignmentEmptyState +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, + onAssignmentClick: (Block) -> Unit, + onViewAllAssignmentsClick: () -> Unit, + getBlockParent: (blockId: String) -> Block?, +) { + 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() } + + 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() + .semantics(mergeDescendants = true) {}, + 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, + sectionName = getBlockParent(firstIncompleteAssignment.id)?.displayName ?: "", + onAssignmentClick = onAssignmentClick, + background = MaterialTheme.appColors.background, + ) + } else { + CaughtUpMessage( + message = stringResource(R.string.course_assignments_caught_up) + ) + } + + Spacer(modifier = Modifier.height(8.dp)) + + // View All Assignments button + ViewAllButton( + text = stringResource(R.string.course_view_all_assignments), + onClick = onViewAllAssignmentsClick + ) + } +} + +@Composable +private fun AssignmentCard( + assignment: Block, + sectionName: String, + onAssignmentClick: (Block) -> Unit, + background: Color = MaterialTheme.appColors.surface +) { + 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_next_assignment) + } + + // Due date status text + val dueDateStatusText = assignment.due?.let { due -> + val formattedDate = TimeUtils.formatToMonthDay(due) + val daysDifference = ((due.time - Date().time) / MILLISECONDS_PER_DAY).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 = background, + 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 + ) { + 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, + 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.displayName, + style = MaterialTheme.appTypography.titleSmall, + color = MaterialTheme.appColors.textPrimary, + fontWeight = FontWeight.Bold + ) + Spacer(modifier = Modifier.height(4.dp)) + Text( + text = sectionName, + 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 new file mode 100644 index 000000000..031a3a145 --- /dev/null +++ b/course/src/main/java/org/openedx/course/presentation/home/CourseCompletionHomePagerCardContent.kt @@ -0,0 +1,161 @@ +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.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.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 +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) + .semantics(mergeDescendants = true) {}, + 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 = { + onSubSectionClick(subsection) + }, + isExpandable = false, + isSectionVisible = true, + showDueDate = false, + useRelativeDates = uiState.useRelativeDates, + subSections = listOf(subsection), + downloadedStateMap = uiState.downloadedState, + onSubSectionClick = onSubSectionClick, + onDownloadClick = onDownloadClick, + progress = progress, + background = MaterialTheme.appColors.background + ) + } + + Spacer(modifier = Modifier.height(8.dp)) + + // View All Content Button + ViewAllButton( + text = stringResource(R.string.course_completion_view_all_content), + onClick = onViewAllContentClick, + modifier = Modifier.align(Alignment.CenterHorizontally) + ) + } +} + +@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, + courseVideos = mapOf(), + courseAssignments = emptyList(), + videoPreview = null, + videoProgress = 0f + ), + onViewAllContentClick = {}, + onDownloadClick = {}, + onSubSectionClick = {}, + ) + } +} 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..d18dad224 --- /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 +} 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..241d51f31 --- /dev/null +++ b/course/src/main/java/org/openedx/course/presentation/home/CourseHomeScreen.kt @@ -0,0 +1,547 @@ +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.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.width +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.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 +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.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 +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import androidx.fragment.app.FragmentManager +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.ui.CircularProgress +import org.openedx.core.ui.HandleUIMessage +import org.openedx.core.ui.NoContentScreen +import org.openedx.core.ui.displayCutoutForLandscape +import org.openedx.core.ui.theme.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 +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 +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 = {}, + onNavigateToProgress: () -> 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, + 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.courseRouter.navigateToCourseContainer( + fragmentManager, + courseId = viewModel.courseId, + unitId = unit.id, + mode = CourseViewMode.FULL + ) + } + } else { + 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) } + }, + onVideoClick = { videoBlock -> + viewModel.courseRouter.navigateToCourseContainer( + fragmentManager, + courseId = viewModel.courseId, + unitId = viewModel.getBlockParent(videoBlock.id)?.id ?: return@CourseHomeUI, + mode = CourseViewMode.VIDEOS + ) + 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, + onNavigateToProgress = onNavigateToProgress, + getBlockParent = viewModel::getBlockParent, + onViewAllContentClick = viewModel::logViewAllContentClick, + onViewAllVideosClick = viewModel::logViewAllVideosClick, + onViewAllAssignmentsClick = viewModel::logViewAllAssignmentsClick, + onViewProgressClick = viewModel::logViewProgressClick + ) +} + +@Composable +private fun CourseHomeUI( + windowSize: WindowSize, + uiState: CourseHomeUIState, + uiMessage: UIMessage?, + homePagerState: PagerState, + onSubSectionClick: (Block) -> Unit, + onResumeClick: (String) -> Unit, + onDownloadClick: (blockIds: List) -> Unit, + onResetDatesClick: () -> Unit, + onCertificateClick: (String) -> Unit, + onVideoClick: (Block) -> Unit, + onAssignmentClick: (Block) -> Unit, + onNavigateToContent: (CourseContentTab) -> Unit, + onNavigateToProgress: () -> Unit, + getBlockParent: (blockId: String) -> Block?, + onViewAllContentClick: () -> Unit, + onViewAllVideosClick: () -> Unit, + onViewAllAssignmentsClick: () -> Unit, + onViewProgressClick: () -> 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() + ) + ) + } + + HandleUIMessage(uiMessage = uiMessage, scaffoldState = scaffoldState) + + Box( + modifier = Modifier + .fillMaxSize() + .padding(it) + .displayCutoutForLandscape(), + contentAlignment = Alignment.TopCenter + ) { + Surface( + modifier = screenWidth, + color = MaterialTheme.appColors.background + ) { + when (uiState) { + is CourseHomeUIState.CourseData -> { + 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 + ), + 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 = { + onViewAllContentClick() + onNavigateToContent(CourseContentTab.ALL) + }, + onDownloadClick = onDownloadClick, + onSubSectionClick = onSubSectionClick + ) + } + + CourseHomePagerTab.VIDEOS -> { + VideosHomePagerCardContent( + uiState = uiState, + onVideoClick = onVideoClick, + onViewAllVideosClick = { + onViewAllVideosClick() + onNavigateToContent(CourseContentTab.VIDEOS) + } + ) + } + + CourseHomePagerTab.ASSIGNMENT -> { + AssignmentsHomePagerCardContent( + uiState = uiState, + onAssignmentClick = onAssignmentClick, + getBlockParent = getBlockParent, + onViewAllAssignmentsClick = { + onViewAllAssignmentsClick() + onNavigateToContent(CourseContentTab.ASSIGNMENTS) + } + ) + } + + CourseHomePagerTab.GRADES -> { + GradesHomePagerCardContent( + uiState = uiState, + onViewProgressClick = { + onViewProgressClick() + onNavigateToProgress() + } + ) + } + } + } + } + } + } + + CourseHomeUIState.Error -> { + NoContentScreen(noContentScreenType = NoContentScreenType.COURSE_OUTLINE) + } + + CourseHomeUIState.Loading -> { + CircularProgress() + } + + CourseHomeUIState.Waiting -> {} + } + } + } + } +} + +@Composable +fun CourseHomePager( + modifier: Modifier = Modifier, + pages: List, + pagerState: PagerState, + pageContent: @Composable (T) -> Unit +) { + HorizontalPager( + modifier = modifier, + state = pagerState, + contentPadding = PaddingValues(horizontal = 16.dp), + pageSpacing = 8.dp, + beyondViewportPageCount = pages.size, + verticalAlignment = Alignment.Top + ) { page -> + pageContent(pages[page]) + } +} + +@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.textAccent, + modifier = Modifier.size(20.dp) + ) + Spacer(modifier = Modifier.width(8.dp)) + Text( + text = text, + style = MaterialTheme.appTypography.labelLarge, + color = MaterialTheme.appColors.textAccent + ) + } +} + +@Composable +fun CaughtUpMessage( + modifier: Modifier = Modifier, + message: String, +) { + 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) +@Preview(uiMode = UI_MODE_NIGHT_YES) +@Composable +private fun CourseHomeScreenPreview() { + OpenEdXTheme { + val previewPagerState = rememberPagerState( + initialPage = 0, + pageCount = { CourseHomePagerTab.entries.size } + ) + CourseHomeUI( + windowSize = WindowSize(WindowType.Compact, WindowType.Compact), + uiState = CourseHomeUIState.CourseData( + 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 + ), + useRelativeDates = true, + courseVideos = mapOf(), + courseAssignments = emptyList(), + videoPreview = null, + videoProgress = 0f + ), + uiMessage = null, + homePagerState = previewPagerState, + onSubSectionClick = {}, + onResumeClick = {}, + onDownloadClick = {}, + onResetDatesClick = {}, + onCertificateClick = {}, + onVideoClick = {}, + onAssignmentClick = {}, + onNavigateToContent = { _ -> }, + onNavigateToProgress = {}, + getBlockParent = { null }, + onViewAllContentClick = {}, + onViewAllVideosClick = {}, + onViewAllAssignmentsClick = {}, + onViewProgressClick = {}, + ) + } +} + +@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 { + val previewPagerState = rememberPagerState( + initialPage = 0, + pageCount = { CourseHomePagerTab.entries.size } + ) + CourseHomeUI( + windowSize = WindowSize(WindowType.Medium, WindowType.Medium), + uiState = CourseHomeUIState.CourseData( + 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 + ), + useRelativeDates = true, + courseVideos = mapOf(), + courseAssignments = emptyList(), + videoPreview = null, + videoProgress = 0f + ), + uiMessage = null, + homePagerState = previewPagerState, + onSubSectionClick = {}, + onResumeClick = {}, + onDownloadClick = {}, + onResetDatesClick = {}, + onCertificateClick = {}, + onVideoClick = {}, + onAssignmentClick = {}, + onNavigateToContent = { _ -> }, + onNavigateToProgress = { }, + getBlockParent = { null }, + onViewAllContentClick = {}, + onViewAllVideosClick = {}, + onViewAllAssignmentsClick = {}, + onViewProgressClick = {}, + ) + } +} 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..773cb07df --- /dev/null +++ b/course/src/main/java/org/openedx/course/presentation/home/CourseHomeUIState.kt @@ -0,0 +1,31 @@ +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 +import org.openedx.core.utils.VideoPreview + +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 subSectionsDownloadsCount: Map, + val datesBannerInfo: CourseDatesBannerInfo, + val useRelativeDates: Boolean, + val courseVideos: Map>, + val courseAssignments: List, + val videoPreview: VideoPreview?, + val videoProgress: Float?, + ) : 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 new file mode 100644 index 000000000..a5f42db5c --- /dev/null +++ b/course/src/main/java/org/openedx/course/presentation/home/CourseHomeViewModel.kt @@ -0,0 +1,670 @@ +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 +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 +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.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.extension.safeDivBy +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.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.course.presentation.unit.container.CourseViewMode +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 context: Context, + 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.Waiting) + 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 val courseVideos = mutableMapOf>() + private val courseAssignments = mutableListOf() + + init { + viewModelScope.launch { + courseNotifier.notifier.collect { event -> + when (event) { + is CourseStructureUpdated -> { + if (event.courseId == courseId) { + getCourseData() + } + } + + is CourseOpenBlock -> { + _resumeBlockId.emit(event.blockId) + } + + is CourseProgressLoaded -> { + getCourseProgress() + } + } + } + } + + 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, + subSectionsDownloadsCount = subSectionsDownloadsCount, + datesBannerInfo = state.datesBannerInfo, + useRelativeDates = preferencesManager.isRelativeDatesEnabled, + next = state.next, + courseProgress = state.courseProgress, + courseVideos = courseVideos, + courseAssignments = courseAssignments, + videoPreview = state.videoPreview, + videoProgress = state.videoProgress + ) + } + } + } + + 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() + } + + 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, true) + combine( + courseStructureFlow, + courseStatusFlow, + 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 { } + } + } + + private suspend fun initializeCourseData( + blocks: List, + courseStructure: CourseStructure, + courseStatus: CourseComponentStatus, + datesBannerInfo: CourseDatesBannerInfo, + courseProgress: CourseProgress + ) { + setBlocks(blocks) + 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) + + sortBlocks(blocks) + initDownloadModelsStatus() + val nextSection = findFirstChapterWithIncompleteDescendants(blocks) + + // Get video data + val allVideos = courseVideos.values.flatten() + val firstIncompleteVideo = allVideos.find { !it.isCompleted() } + val videoProgress = if (firstIncompleteVideo != null) { + try { + val videoProgressEntity = interactor.getVideoProgress(firstIncompleteVideo.id) + 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 + } + } else { + 0f + } + + _uiState.value = CourseHomeUIState.CourseData( + courseStructure = courseStructure, + next = nextSection, + downloadedState = getDownloadModelsStatus(), + resumeComponent = getResumeBlock(blocks, courseStatus.lastVisitedBlockId), + resumeUnitTitle = resumeVerticalBlock?.displayName ?: "", + courseSubSections = courseSubSections, + subSectionsDownloadsCount = subSectionsDownloadsCount, + datesBannerInfo = datesBannerInfo, + useRelativeDates = preferencesManager.isRelativeDatesEnabled, + courseProgress = courseProgress, + courseVideos = courseVideos, + courseAssignments = courseAssignments, + 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?) { + _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) + + // 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) + } + } + + private fun addSequentialBlockToSubSections(block: Block, sequentialBlock: Block) { + 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, + ): 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 + } + + /** + * 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() } + 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? { + // 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 { + 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 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) + } + ) + } + } + } + + fun getCourseProgress() { + viewModelScope.launch { + if (_uiState.value !is CourseHomeUIState.CourseData) { + _uiState.value = CourseHomeUIState.Loading + } + interactor.getCourseProgress(courseId, false, true) + .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) + } + } + } + } + + fun logVideoClick(blockId: String) { + val currentState = uiState.value + if (currentState is CourseHomeUIState.CourseData) { + analytics.logEvent( + CourseAnalyticsEvent.COURSE_HOME_VIDEO_CLICK.eventName, + buildMap { + put( + CourseAnalyticsKey.NAME.key, + 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) + } + ) + } + } + + fun logAssignmentClick(blockId: String) { + val currentState = uiState.value + if (currentState is CourseHomeUIState.CourseData) { + analytics.logEvent( + 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_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) + } + ) + } + } +} 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..962732203 --- /dev/null +++ b/course/src/main/java/org/openedx/course/presentation/home/GradesHomePagerCardContent.kt @@ -0,0 +1,221 @@ +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.semantics.contentDescription +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 +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 +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 + +@Composable +fun GradesHomePagerCardContent( + uiState: CourseHomeUIState.CourseData, + onViewProgressClick: () -> Unit +) { + 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() + return + } + + 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)) + CurrentOverallGradeText(progress = courseProgress) + Spacer(modifier = Modifier.height(12.dp)) + 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 = assignmentPolicies, + assignmentColors = gradingPolicy.assignmentColors, + progress = courseProgress, + courseStructure = uiState.courseStructure + ) + Spacer(modifier = Modifier.height(8.dp)) + ViewAllButton( + text = stringResource(R.string.course_view_progress), + onClick = onViewProgressClick, + ) + } +} + +@Composable +private fun GradeCard( + policy: CourseProgress.GradingPolicy.AssignmentPolicy, + progress: CourseProgress, + courseStructure: CourseStructure?, + color: Color, + modifier: Modifier = Modifier +) { + 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( + modifier = modifier + .fillMaxWidth() + .semantics(mergeDescendants = true) {}, + 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 = "$gradePercent%", + 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, + possible + ), + style = MaterialTheme.appTypography.labelSmall, + color = MaterialTheme.appColors.textPrimary, + ) + } + } + } + } +} + +@Composable +private fun GradeCardsGrid( + assignmentPolicies: List, + assignmentColors: List, + progress: CourseProgress, + courseStructure: CourseStructure? +) { + 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( + modifier = Modifier.weight(1f), + policy = policy, + progress = progress, + courseStructure = courseStructure, + color = if (assignmentColors.isNotEmpty()) { + assignmentColors[policyIndex % assignmentColors.size] + } else { + MaterialTheme.appColors.primary + }, + ) + } + // 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 new file mode 100644 index 000000000..ecbeec99b --- /dev/null +++ b/course/src/main/java/org/openedx/course/presentation/home/VideosHomePagerCardContent.kt @@ -0,0 +1,184 @@ +package org.openedx.course.presentation.home + +import androidx.compose.foundation.BorderStroke +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.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.icons.Icons +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.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 +import org.openedx.course.presentation.ui.CourseVideoItem + +@Composable +fun VideosHomePagerCardContent( + uiState: CourseHomeUIState.CourseData, + onVideoClick: (Block) -> Unit, + 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() } + val videoProgress = uiState.videoProgress ?: if (firstIncompleteVideo?.isCompleted() ?: false) { + 1f + } else { + 0f + } + + 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() + .semantics(mergeDescendants = true) {}, + 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) { + val title = if (videoProgress > 0) { + stringResource(R.string.course_continue_watching) + } else { + stringResource(R.string.course_next_video) + } + Text( + text = title, + style = MaterialTheme.appTypography.titleMedium, + color = MaterialTheme.appColors.textPrimary, + fontWeight = FontWeight.SemiBold + ) + + Spacer(modifier = Modifier.height(8.dp)) + + // Video card using CourseVideoItem + 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 = 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( + message = stringResource(R.string.course_videos_caught_up) + ) + } + + Spacer(modifier = Modifier.height(8.dp)) + + // View All Videos button + ViewAllButton( + text = stringResource(R.string.course_view_all_videos), + onClick = onViewAllVideosClick + ) + } +} 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..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 @@ -2,24 +2,20 @@ 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 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 -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 +30,22 @@ 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 +53,13 @@ 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.course.presentation.unit.container.CourseViewMode 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 +286,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 +308,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 @@ -337,6 +325,7 @@ private fun CourseContentAllUI( CourseContentAllUIState.Error -> { CourseContentAllEmptyState( + modifier = Modifier.verticalScroll(rememberScrollState()), onReturnToCourseClick = onNavigateToHome ) } @@ -351,44 +340,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 +357,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 +393,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 +426,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/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/progress/CourseProgressScreen.kt b/course/src/main/java/org/openedx/course/presentation/progress/CourseProgressScreen.kt index 47a01e416..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 @@ -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,9 +144,9 @@ private fun CourseProgressContent( item { GradeDetailsHeaderView() } - itemsIndexed(gradingPolicy.assignmentPolicies) { index, policy -> + itemsIndexed(assignmentPolicies) { index, policy -> AssignmentTypeRow( - progress = uiState.progress, + uiState = uiState, policy = policy, color = if (gradingPolicy.assignmentColors.isNotEmpty()) { gradingPolicy.assignmentColors[index % gradingPolicy.assignmentColors.size] @@ -234,7 +235,7 @@ private fun GradeDetailsHeaderView() { } @Composable -private fun GradeDetailsFooterView( +fun GradeDetailsFooterView( progress: CourseProgress, ) { Row( @@ -276,108 +277,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( @@ -434,53 +341,23 @@ 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) + ) } } @Composable private fun AssignmentTypeRow( - progress: CourseProgress, + uiState: CourseProgressUIState.Data, policy: CourseProgress.GradingPolicy.AssignmentPolicy, color: Color ) { - val earned = progress.getEarnedAssignmentProblems(policy) - val possible = progress.getPossibleAssignmentProblems(policy) + val assignments = uiState.progress.getAssignmentSections(policy.type) + val earned = uiState.progress.getCompletedAssignmentCount(policy, uiState.courseStructure) + val possible = assignments.size Column( modifier = Modifier .semantics(mergeDescendants = true) {} @@ -514,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, @@ -535,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, @@ -545,3 +422,168 @@ 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, + ) + } + } +} + +@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) + .background(MaterialTheme.appColors.gradeProgressBarBackground) + .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/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 c5c4b1f06..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,10 +8,11 @@ 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 +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 @@ -33,30 +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) - .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)) + 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) + } } } @@ -64,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/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/ui/CourseUI.kt b/course/src/main/java/org/openedx/course/presentation/ui/CourseUI.kt index 54075d183..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 @@ -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 @@ -77,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 @@ -103,6 +106,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 @@ -614,7 +618,7 @@ fun CourseVideoSection( block: Block, videoBlocks: List, preview: Map, - progress: Map, + progress: Map, downloadedStateMap: Map, onVideoClick: (Block) -> Unit, onDownloadClick: (blocksIds: List) -> Unit, @@ -628,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 { @@ -651,19 +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(videoCardWidth) + .height(108.dp) + .clip(MaterialTheme.appShapes.videoPreviewShape), videoBlock = block, preview = preview[block.id], - progress = progress[block.id] ?: 0f, + progress = progress, onClick = { onVideoClick(block) } @@ -676,16 +692,17 @@ fun CourseVideoSection( @Composable fun CourseVideoItem( + modifier: Modifier = Modifier, 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 - .width(192.dp) - .height(108.dp) - .clip(MaterialTheme.appShapes.videoPreviewShape) + modifier = modifier .let { if (videoBlock.isCompleted()) { it.border( @@ -726,58 +743,65 @@ fun CourseVideoItem( ) ) - Image( - modifier = Modifier - .size(32.dp) - .align(Alignment.Center), - painter = painterResource(id = R.drawable.course_video_play_button), - contentDescription = null, - ) + Box( + modifier = contentModifier.fillMaxSize() + ) { + 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 - ) + // Title (top-left) + Text( + text = videoBlock.displayName, + color = Color.White, + style = titleStyle, + modifier = Modifier + .align(Alignment.TopStart), + maxLines = 2, + overflow = TextOverflow.Ellipsis + ) - // Progress bar (bottom) - if (progress > 0.0f) { + // Progress bar (bottom) Box( modifier = Modifier - .padding(bottom = 4.dp) - .height(16.dp) + .fillMaxWidth() .align(Alignment.BottomCenter), contentAlignment = Alignment.Center ) { - LinearProgressIndicator( - 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 (progress > 0.0f) { + 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 = (-4).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), + ) } } } @@ -850,7 +874,7 @@ fun DownloadIcon( val downloadIconTint = if (downloadedState == DownloadedState.DOWNLOADED) { MaterialTheme.appColors.successGreen } else { - MaterialTheme.appColors.textAccent + MaterialTheme.appColors.primary } IconButton( modifier = iconModifier, @@ -900,14 +924,18 @@ fun DownloadIcon( @Composable fun CourseSection( modifier: Modifier = Modifier, - block: Block, + section: Block, useRelativeDates: Boolean, + showDueDate: Boolean = true, + isExpandable: Boolean = true, onItemClick: (Block) -> Unit, isSectionVisible: Boolean?, - courseSubSections: List?, + subSections: List?, downloadedStateMap: Map, onSubSectionClick: (Block) -> Unit, onDownloadClick: (blocksIds: List) -> Unit, + progress: Float? = null, + background: Color = MaterialTheme.appColors.cardViewBackground ) { val arrowRotation by animateFloatAsState( targetValue = if (isSectionVisible == true) { @@ -917,7 +945,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,15 +955,15 @@ 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) } - .background(MaterialTheme.appColors.cardViewBackground) + .noRippleClickable { onItemClick(section) } + .background(background) .border( 1.dp, MaterialTheme.appColors.cardViewBorder, @@ -951,20 +979,22 @@ 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 ) { CourseSubSectionItem( block = subSectionBlock, onClick = onSubSectionClick, + showDueDate = showDueDate, useRelativeDates = useRelativeDates ) } @@ -977,6 +1007,7 @@ fun CourseExpandableChapterCard( modifier: Modifier = Modifier, block: Block, arrowDegrees: Float = 0f, + isExpandable: Boolean = true, downloadedState: DownloadedState?, onDownloadClick: () -> Unit, ) { @@ -989,7 +1020,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 +1038,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 @@ -1022,6 +1055,7 @@ fun CourseSubSectionItem( modifier: Modifier = Modifier, block: Block, useRelativeDates: Boolean, + showDueDate: Boolean, onClick: (Block) -> Unit, ) { val context = LocalContext.current @@ -1065,7 +1099,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, @@ -1093,7 +1127,7 @@ fun CourseSubSectionItem( .filter { !it.isNullOrEmpty() } .joinToString(" - ") - if (assignmentString.isNotEmpty()) { + if (assignmentString.isNotEmpty() && showDueDate) { Spacer(modifier = Modifier.height(8.dp)) Text( text = assignmentString, @@ -1538,6 +1572,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 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/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 571fde683..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 @@ -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 @@ -35,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 @@ -45,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 @@ -142,6 +144,7 @@ private fun CourseVideosUI( when (uiState) { is CourseVideoUIState.Empty -> { CourseContentVideoEmptyState( + modifier = Modifier.verticalScroll(rememberScrollState()), onReturnToCourseClick = onNavigateToHome ) } @@ -179,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 c37b8709e..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 @@ -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,20 +147,15 @@ 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() - .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 = @@ -176,11 +170,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 +184,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/main/res/values/strings.xml b/course/src/main/res/values/strings.xml index 33852c242..dd59fcf8f 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 @@ -77,8 +85,33 @@ %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 + + + Continue Watching + Next Video + View All Videos + %1$s left + Videos\ncompleted + + + Assignments\ncompleted + View All Assignments + Next Assignment + Due Today: %1$s + %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 + + + You\'re all caught up on videos! + You\'re all caught up on assignments! 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..cbd0d04f0 --- /dev/null +++ b/course/src/test/java/org/openedx/course/presentation/home/CourseHomeViewModelTest.kt @@ -0,0 +1,863 @@ +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 +import org.openedx.course.R as courseR + +@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() + 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(courseR.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, + true + ) + } 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, true) } + + 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, + true + ) + } 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) + } + + @Suppress("TooGenericExceptionThrown") + @Test + fun `getCourseData unknown error`() = runTest { + coEvery { + interactor.getCourseStructureFlow( + courseId, + false + ) + } returns flow { throw Exception() } + coEvery { interactor.getCourseStatusFlow(courseId) } returns flow { + emit( + courseComponentStatus + ) + } + coEvery { interactor.getCourseDatesFlow(courseId) } returns flow { emit(courseDatesResult) } + coEvery { + interactor.getCourseProgress( + courseId, + false, + true + ) + } 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, + true + ) + } 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, + true + ) + } 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, + true + ) + } 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, + true + ) + } 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, + true + ) + } 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, + true + ) + } 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, + true + ) + } 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, true) } + } + + @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, + true + ) + } 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, + true + ) + } 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, + true + ) + } 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, true) } + } + + @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, + true + ) + } 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) + } +} 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 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() {