From eb64023721145a68571771c4ef212910e139d4c4 Mon Sep 17 00:00:00 2001 From: PavloNetrebchuk Date: Mon, 20 May 2024 18:31:13 +0300 Subject: [PATCH 01/18] feat: Course Home progress bar --- .../core/data/model/CourseStructureModel.kt | 11 ++-- .../org/openedx/core/data/model/Progress.kt | 23 +++++++++ .../data/model/room/CourseStructureEntity.kt | 8 ++- .../room/discovery/EnrolledCourseEntity.kt | 20 +++++++- .../core/domain/model/CourseStructure.kt | 3 +- .../org/openedx/core/domain/model/Progress.kt | 16 ++++++ .../org/openedx/core/ui/theme/AppColors.kt | 5 +- .../java/org/openedx/core/ui/theme/Theme.kt | 10 +++- .../org/openedx/core/ui/theme/Colors.kt | 6 ++- .../container/CourseContainerViewModel.kt | 2 +- .../outline/CourseOutlineScreen.kt | 50 ++++++++++++++++++- .../course/presentation/ui/CourseVideosUI.kt | 4 +- course/src/main/res/values/strings.xml | 1 + 13 files changed, 145 insertions(+), 14 deletions(-) create mode 100644 core/src/main/java/org/openedx/core/data/model/Progress.kt create mode 100644 core/src/main/java/org/openedx/core/domain/model/Progress.kt diff --git a/core/src/main/java/org/openedx/core/data/model/CourseStructureModel.kt b/core/src/main/java/org/openedx/core/data/model/CourseStructureModel.kt index 9f22a14a0..d09411d14 100644 --- a/core/src/main/java/org/openedx/core/data/model/CourseStructureModel.kt +++ b/core/src/main/java/org/openedx/core/data/model/CourseStructureModel.kt @@ -4,6 +4,7 @@ import com.google.gson.annotations.SerializedName import org.openedx.core.data.model.room.BlockDb import org.openedx.core.data.model.room.CourseStructureEntity import org.openedx.core.data.model.room.MediaDb +import org.openedx.core.data.model.room.discovery.ProgressDb import org.openedx.core.domain.model.CourseStructure import org.openedx.core.utils.TimeUtils @@ -35,7 +36,9 @@ data class CourseStructureModel( @SerializedName("certificate") val certificate: Certificate?, @SerializedName("is_self_paced") - var isSelfPaced: Boolean? + var isSelfPaced: Boolean?, + @SerializedName("course_progress") + val progress: Progress?, ) { fun mapToDomain(): CourseStructure { return CourseStructure( @@ -54,7 +57,8 @@ data class CourseStructureModel( coursewareAccess = coursewareAccess?.mapToDomain(), media = media?.mapToDomain(), certificate = certificate?.mapToDomain(), - isSelfPaced = isSelfPaced ?: false + isSelfPaced = isSelfPaced ?: false, + progress = progress?.mapToDomain() ) } @@ -73,7 +77,8 @@ data class CourseStructureModel( coursewareAccess = coursewareAccess?.mapToRoomEntity(), media = MediaDb.createFrom(media), certificate = certificate?.mapToRoomEntity(), - isSelfPaced = isSelfPaced ?: false + isSelfPaced = isSelfPaced ?: false, + progress = progress?.mapToRoomEntity() ?: ProgressDb.DEFAULT_PROGRESS ) } } diff --git a/core/src/main/java/org/openedx/core/data/model/Progress.kt b/core/src/main/java/org/openedx/core/data/model/Progress.kt new file mode 100644 index 000000000..057148ea9 --- /dev/null +++ b/core/src/main/java/org/openedx/core/data/model/Progress.kt @@ -0,0 +1,23 @@ +package org.openedx.core.data.model + +import com.google.gson.annotations.SerializedName +import org.openedx.core.data.model.room.discovery.ProgressDb + +data class Progress( + @SerializedName("assignments_completed") + val assignmentsCompleted: Int?, + @SerializedName("total_assignments_count") + val totalAssignmentsCount: Int? +) { + fun mapToDomain(): org.openedx.core.domain.model.Progress { + return org.openedx.core.domain.model.Progress( + assignmentsCompleted = assignmentsCompleted ?: 0, + totalAssignmentsCount = totalAssignmentsCount ?: 0 + ) + } + + fun mapToRoomEntity() = ProgressDb( + assignmentsCompleted = assignmentsCompleted ?: 0, + totalAssignmentsCount = totalAssignmentsCount ?: 0 + ) +} \ No newline at end of file diff --git a/core/src/main/java/org/openedx/core/data/model/room/CourseStructureEntity.kt b/core/src/main/java/org/openedx/core/data/model/room/CourseStructureEntity.kt index 90352d821..49862d683 100644 --- a/core/src/main/java/org/openedx/core/data/model/room/CourseStructureEntity.kt +++ b/core/src/main/java/org/openedx/core/data/model/room/CourseStructureEntity.kt @@ -6,6 +6,7 @@ import androidx.room.Entity import androidx.room.PrimaryKey import org.openedx.core.data.model.room.discovery.CertificateDb import org.openedx.core.data.model.room.discovery.CoursewareAccessDb +import org.openedx.core.data.model.room.discovery.ProgressDb import org.openedx.core.domain.model.CourseStructure import org.openedx.core.utils.TimeUtils @@ -39,7 +40,9 @@ data class CourseStructureEntity( @Embedded val certificate: CertificateDb?, @ColumnInfo("isSelfPaced") - val isSelfPaced: Boolean + val isSelfPaced: Boolean, + @Embedded + val progress: ProgressDb, ) { fun mapToDomain(): CourseStructure { @@ -57,7 +60,8 @@ data class CourseStructureEntity( coursewareAccess?.mapToDomain(), media?.mapToDomain(), certificate?.mapToDomain(), - isSelfPaced + isSelfPaced, + progress.mapToDomain() ) } diff --git a/core/src/main/java/org/openedx/core/data/model/room/discovery/EnrolledCourseEntity.kt b/core/src/main/java/org/openedx/core/data/model/room/discovery/EnrolledCourseEntity.kt index 05aab3bdd..87cc68e49 100644 --- a/core/src/main/java/org/openedx/core/data/model/room/discovery/EnrolledCourseEntity.kt +++ b/core/src/main/java/org/openedx/core/data/model/room/discovery/EnrolledCourseEntity.kt @@ -5,7 +5,12 @@ import androidx.room.Embedded import androidx.room.Entity import androidx.room.PrimaryKey import org.openedx.core.data.model.room.MediaDb -import org.openedx.core.domain.model.* +import org.openedx.core.domain.model.Certificate +import org.openedx.core.domain.model.CourseSharingUtmParameters +import org.openedx.core.domain.model.CoursewareAccess +import org.openedx.core.domain.model.EnrolledCourse +import org.openedx.core.domain.model.EnrolledCourseData +import org.openedx.core.domain.model.Progress import org.openedx.core.utils.TimeUtils @Entity(tableName = "course_enrolled_table") @@ -135,6 +140,19 @@ data class CoursewareAccessDb( } +data class ProgressDb( + @ColumnInfo("assignments_completed") + val assignmentsCompleted: Int, + @ColumnInfo("total_assignments_count") + val totalAssignmentsCount: Int, +) { + companion object { + val DEFAULT_PROGRESS = ProgressDb(0, 0) + } + + fun mapToDomain() = Progress(assignmentsCompleted, totalAssignmentsCount) +} + data class CertificateDb( @ColumnInfo("certificateURL") val certificateURL: String? diff --git a/core/src/main/java/org/openedx/core/domain/model/CourseStructure.kt b/core/src/main/java/org/openedx/core/domain/model/CourseStructure.kt index bdb3820de..4ba3a8419 100644 --- a/core/src/main/java/org/openedx/core/domain/model/CourseStructure.kt +++ b/core/src/main/java/org/openedx/core/domain/model/CourseStructure.kt @@ -16,5 +16,6 @@ data class CourseStructure( val coursewareAccess: CoursewareAccess?, val media: Media?, val certificate: Certificate?, - val isSelfPaced: Boolean + val isSelfPaced: Boolean, + val progress: Progress?, ) diff --git a/core/src/main/java/org/openedx/core/domain/model/Progress.kt b/core/src/main/java/org/openedx/core/domain/model/Progress.kt new file mode 100644 index 000000000..8362a8133 --- /dev/null +++ b/core/src/main/java/org/openedx/core/domain/model/Progress.kt @@ -0,0 +1,16 @@ +package org.openedx.core.domain.model + +import android.os.Parcelable +import kotlinx.parcelize.Parcelize + +@Parcelize +data class Progress( + val assignmentsCompleted: Int, + val totalAssignmentsCount: Int, +) : Parcelable { + fun getProgress(): Float = try { + assignmentsCompleted.toFloat() / totalAssignmentsCount.toFloat() + } catch (_: ArithmeticException) { + 0f + } +} \ No newline at end of file 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 4b7a0ba10..0095b27f5 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 @@ -58,7 +58,10 @@ data class AppColors( val courseHomeHeaderShade: Color, val courseHomeBackBtnBackground: Color, - val settingsTitleContent: Color + val settingsTitleContent: Color, + + val progressBarColor: Color, + val progressBarBackgroundColor: 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 1ffa3c73d..3235074b7 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 @@ -78,7 +78,10 @@ private val DarkColorPalette = AppColors( courseHomeHeaderShade = dark_course_home_header_shade, courseHomeBackBtnBackground = dark_course_home_back_btn_background, - settingsTitleContent = dark_settings_title_content + settingsTitleContent = dark_settings_title_content, + + progressBarColor = dark_progress_bar_color, + progressBarBackgroundColor = dark_progress_bar_background_color ) private val LightColorPalette = AppColors( @@ -149,7 +152,10 @@ private val LightColorPalette = AppColors( courseHomeHeaderShade = light_course_home_header_shade, courseHomeBackBtnBackground = light_course_home_back_btn_background, - settingsTitleContent = light_settings_title_content + settingsTitleContent = light_settings_title_content, + + progressBarColor = light_progress_bar_color, + progressBarBackgroundColor = light_progress_bar_background_color ) val MaterialTheme.appColors: AppColors diff --git a/core/src/openedx/org/openedx/core/ui/theme/Colors.kt b/core/src/openedx/org/openedx/core/ui/theme/Colors.kt index 1cc4c3495..13a00cc81 100644 --- a/core/src/openedx/org/openedx/core/ui/theme/Colors.kt +++ b/core/src/openedx/org/openedx/core/ui/theme/Colors.kt @@ -56,9 +56,11 @@ val light_tab_selected_btn_content = Color.White val light_course_home_header_shade = Color(0xFFBABABA) val light_course_home_back_btn_background = Color.White val light_settings_title_content = Color.White +val light_progress_bar_color = light_primary +val light_progress_bar_background_color = Color(0xFF97A5BB) -val dark_primary = Color(0xFF5478F9) +val dark_primary = Color(0xFF3F68F8) val dark_primary_variant = Color(0xFF3700B3) val dark_secondary = Color(0xFF03DAC6) val dark_secondary_variant = Color(0xFF373E4F) @@ -112,3 +114,5 @@ val dark_tab_selected_btn_content = Color.White val dark_course_home_header_shade = Color(0xFF999999) val dark_course_home_back_btn_background = Color.Black val dark_settings_title_content = Color.White +val dark_progress_bar_color = light_primary +val dark_progress_bar_background_color = Color(0xFF8E9BAE) diff --git a/course/src/main/java/org/openedx/course/presentation/container/CourseContainerViewModel.kt b/course/src/main/java/org/openedx/course/presentation/container/CourseContainerViewModel.kt index 8562289af..a757fd7ec 100644 --- a/course/src/main/java/org/openedx/course/presentation/container/CourseContainerViewModel.kt +++ b/course/src/main/java/org/openedx/course/presentation/container/CourseContainerViewModel.kt @@ -168,7 +168,7 @@ class CourseContainerViewModel( _showProgress.value = true viewModelScope.launch { try { - val courseStructure = interactor.getCourseStructure(courseId) + val courseStructure = interactor.getCourseStructure(courseId, true) courseName = courseStructure.name _organization = courseStructure.org _isSelfPaced = courseStructure.isSelfPaced diff --git a/course/src/main/java/org/openedx/course/presentation/outline/CourseOutlineScreen.kt b/course/src/main/java/org/openedx/course/presentation/outline/CourseOutlineScreen.kt index 7e950cba8..734c74151 100644 --- a/course/src/main/java/org/openedx/course/presentation/outline/CourseOutlineScreen.kt +++ b/course/src/main/java/org/openedx/course/presentation/outline/CourseOutlineScreen.kt @@ -18,8 +18,10 @@ import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.shape.CircleShape import androidx.compose.material.Divider import androidx.compose.material.Icon +import androidx.compose.material.LinearProgressIndicator import androidx.compose.material.MaterialTheme import androidx.compose.material.Scaffold import androidx.compose.material.Surface @@ -32,6 +34,7 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip import androidx.compose.ui.platform.AndroidUriHandler import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.painterResource @@ -49,6 +52,7 @@ 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.Progress import org.openedx.core.extension.takeIfNotEmpty import org.openedx.core.presentation.course.CourseViewMode import org.openedx.core.ui.HandleUIMessage @@ -272,6 +276,19 @@ private fun CourseOutlineUI( } } } + + val progress = uiState.courseStructure.progress + if (progress != null && progress.totalAssignmentsCount > 0) { + item { + CourseProgress( + modifier = Modifier + .fillMaxWidth() + .padding(top = 16.dp, start = 24.dp, end = 24.dp), + progress = progress + ) + } + } + if (uiState.resumeComponent != null) { item { Box(listPadding) { @@ -484,6 +501,36 @@ private fun ResumeCourseTablet( } } +@Composable +private fun CourseProgress( + modifier: Modifier = Modifier, + progress: Progress +) { + Column( + modifier = modifier, + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + LinearProgressIndicator( + modifier = Modifier + .fillMaxWidth() + .height(10.dp) + .clip(CircleShape), + progress = progress.getProgress(), + color = MaterialTheme.appColors.progressBarColor, + backgroundColor = MaterialTheme.appColors.progressBarBackgroundColor + ) + Text( + text = stringResource( + R.string.course_assignments_complete, + progress.assignmentsCompleted, + progress.totalAssignmentsCount + ), + color = MaterialTheme.appColors.textDark, + style = MaterialTheme.appTypography.labelSmall + ) + } +} + fun getUnitBlockIcon(block: Block): Int { return when (block.type) { BlockType.VIDEO -> R.drawable.ic_course_video @@ -628,5 +675,6 @@ private val mockCourseStructure = CourseStructure( ), media = null, certificate = null, - isSelfPaced = false + isSelfPaced = false, + progress = Progress(1, 3) ) diff --git a/course/src/main/java/org/openedx/course/presentation/ui/CourseVideosUI.kt b/course/src/main/java/org/openedx/course/presentation/ui/CourseVideosUI.kt index c69e26c0d..b4716a67d 100644 --- a/course/src/main/java/org/openedx/course/presentation/ui/CourseVideosUI.kt +++ b/course/src/main/java/org/openedx/course/presentation/ui/CourseVideosUI.kt @@ -63,6 +63,7 @@ import org.openedx.core.domain.model.Block import org.openedx.core.domain.model.BlockCounts import org.openedx.core.domain.model.CourseStructure import org.openedx.core.domain.model.CoursewareAccess +import org.openedx.core.domain.model.Progress import org.openedx.core.domain.model.VideoSettings import org.openedx.core.extension.toFileSize import org.openedx.core.module.download.DownloadModelsSize @@ -849,5 +850,6 @@ private val mockCourseStructure = CourseStructure( ), media = null, certificate = null, - isSelfPaced = false + isSelfPaced = false, + progress = Progress(1, 3) ) diff --git a/course/src/main/res/values/strings.xml b/course/src/main/res/values/strings.xml index c6b370267..3d48a9e78 100644 --- a/course/src/main/res/values/strings.xml +++ b/course/src/main/res/values/strings.xml @@ -95,5 +95,6 @@ Turning off the switch will stop downloading and delete all downloaded videos for \"%s\"? Are you sure you want to delete all video(s) for \"%s\"? Are you sure you want to delete video(s) for \"%s\"? + %1$s of %2$s assignments complete From ff0bfd107ad84c32500604f8f2904c7e5eedbc93 Mon Sep 17 00:00:00 2001 From: PavloNetrebchuk Date: Tue, 21 May 2024 22:59:12 +0300 Subject: [PATCH 02/18] feat: Collapsing course sections --- .../core/data/model/AssignmentProgress.kt | 25 ++ .../java/org/openedx/core/data/model/Block.kt | 9 +- .../openedx/core/data/model/room/BlockDb.kt | 36 +- .../core/domain/model/AssignmentProgress.kt | 7 + .../org/openedx/core/domain/model/Block.kt | 5 +- .../org/openedx/core/ui/theme/AppColors.kt | 2 +- .../java/org/openedx/core/ui/theme/Theme.kt | 4 +- .../org/openedx/core/ui/theme/Colors.kt | 4 +- .../outline/CourseOutlineScreen.kt | 173 +++----- .../outline/CourseOutlineViewModel.kt | 20 +- .../section/CourseSectionFragment.kt | 47 +- .../course/presentation/ui/CourseUI.kt | 401 ++++++++---------- .../course/presentation/ui/CourseVideosUI.kt | 159 +++---- .../videos/CourseVideoViewModel.kt | 16 +- .../res/drawable/course_ic_start_download.xml | 28 +- .../presentation/settings/SettingsScreenUI.kt | 2 +- 16 files changed, 418 insertions(+), 520 deletions(-) create mode 100644 core/src/main/java/org/openedx/core/data/model/AssignmentProgress.kt create mode 100644 core/src/main/java/org/openedx/core/domain/model/AssignmentProgress.kt diff --git a/core/src/main/java/org/openedx/core/data/model/AssignmentProgress.kt b/core/src/main/java/org/openedx/core/data/model/AssignmentProgress.kt new file mode 100644 index 000000000..040087349 --- /dev/null +++ b/core/src/main/java/org/openedx/core/data/model/AssignmentProgress.kt @@ -0,0 +1,25 @@ +package org.openedx.core.data.model + +import com.google.gson.annotations.SerializedName +import org.openedx.core.data.model.room.AssignmentProgressDb + +data class AssignmentProgress( + @SerializedName("assignment_type") + val assignmentType: String?, + @SerializedName("num_points_earned") + val numPointsEarned: Float?, + @SerializedName("num_points_possible") + val numPointsPossible: Float?, +) { + fun mapToDomain() = org.openedx.core.domain.model.AssignmentProgress( + assignmentType = assignmentType ?: "", + numPointsEarned = numPointsEarned ?: 0f, + numPointsPossible = numPointsPossible ?: 0f + ) + + fun mapToRoomEntity() = AssignmentProgressDb( + assignmentType = assignmentType, + numPointsEarned = numPointsEarned, + numPointsPossible = numPointsPossible + ) +} diff --git a/core/src/main/java/org/openedx/core/data/model/Block.kt b/core/src/main/java/org/openedx/core/data/model/Block.kt index 9c07367ac..07ff5d49a 100644 --- a/core/src/main/java/org/openedx/core/data/model/Block.kt +++ b/core/src/main/java/org/openedx/core/data/model/Block.kt @@ -3,6 +3,7 @@ package org.openedx.core.data.model import com.google.gson.annotations.SerializedName import org.openedx.core.BlockType import org.openedx.core.domain.model.Block +import org.openedx.core.utils.TimeUtils data class Block( @SerializedName("id") @@ -33,6 +34,10 @@ data class Block( val completion: Double?, @SerializedName("contains_gated_content") val containsGatedContent: Boolean?, + @SerializedName("assignment_progress") + val assignmentProgress: AssignmentProgress?, + @SerializedName("due") + val due: String? ) { fun mapToDomain(blockData: Map): Block { val blockType = BlockType.getBlockType(type ?: "") @@ -61,7 +66,9 @@ data class Block( studentViewMultiDevice = studentViewMultiDevice ?: false, blockCounts = blockCounts?.mapToDomain()!!, completion = completion ?: 0.0, - containsGatedContent = containsGatedContent ?: false + containsGatedContent = containsGatedContent ?: false, + assignmentProgress = assignmentProgress?.mapToDomain(), + due = TimeUtils.iso8601ToDate(due ?: ""), ) } } diff --git a/core/src/main/java/org/openedx/core/data/model/room/BlockDb.kt b/core/src/main/java/org/openedx/core/data/model/room/BlockDb.kt index b1e9a53cf..5f362862f 100644 --- a/core/src/main/java/org/openedx/core/data/model/room/BlockDb.kt +++ b/core/src/main/java/org/openedx/core/data/model/room/BlockDb.kt @@ -3,7 +3,12 @@ package org.openedx.core.data.model.room import androidx.room.ColumnInfo import androidx.room.Embedded import org.openedx.core.BlockType -import org.openedx.core.domain.model.* +import org.openedx.core.domain.model.Block +import org.openedx.core.domain.model.BlockCounts +import org.openedx.core.domain.model.EncodedVideos +import org.openedx.core.domain.model.StudentViewData +import org.openedx.core.domain.model.VideoInfo +import org.openedx.core.utils.TimeUtils data class BlockDb( @ColumnInfo("id") @@ -33,7 +38,11 @@ data class BlockDb( @ColumnInfo("completion") val completion: Double, @ColumnInfo("contains_gated_content") - val containsGatedContent: Boolean + val containsGatedContent: Boolean, + @Embedded + val assignmentProgress: AssignmentProgressDb?, + @ColumnInfo("due") + val due: String? ) { fun mapToDomain(blocks: List): Block { val blockType = BlockType.getBlockType(type) @@ -62,7 +71,9 @@ data class BlockDb( descendants = descendants, descendantsType = descendantsType, completion = completion, - containsGatedContent = containsGatedContent + containsGatedContent = containsGatedContent, + assignmentProgress = assignmentProgress?.mapToDomain(), + due = TimeUtils.iso8601ToDate(due ?: ""), ) } @@ -86,7 +97,9 @@ data class BlockDb( studentViewMultiDevice = studentViewMultiDevice ?: false, blockCounts = BlockCountsDb.createFrom(blockCounts), completion = completion ?: 0.0, - containsGatedContent = containsGatedContent ?: false + containsGatedContent = containsGatedContent ?: false, + assignmentProgress = assignmentProgress?.mapToRoomEntity(), + due = due ) } } @@ -201,3 +214,18 @@ data class BlockCountsDb( } } } + +data class AssignmentProgressDb( + @ColumnInfo("assignment_type") + val assignmentType: String?, + @ColumnInfo("num_points_earned") + val numPointsEarned: Float?, + @ColumnInfo("num_points_possible") + val numPointsPossible: Float?, +) { + fun mapToDomain() = org.openedx.core.domain.model.AssignmentProgress( + assignmentType = assignmentType ?: "", + numPointsEarned = numPointsEarned ?: 0f, + numPointsPossible = numPointsPossible ?: 0f + ) +} diff --git a/core/src/main/java/org/openedx/core/domain/model/AssignmentProgress.kt b/core/src/main/java/org/openedx/core/domain/model/AssignmentProgress.kt new file mode 100644 index 000000000..659665bfe --- /dev/null +++ b/core/src/main/java/org/openedx/core/domain/model/AssignmentProgress.kt @@ -0,0 +1,7 @@ +package org.openedx.core.domain.model + +data class AssignmentProgress( + val assignmentType: String, + val numPointsEarned: Float, + val numPointsPossible: Float +) diff --git a/core/src/main/java/org/openedx/core/domain/model/Block.kt b/core/src/main/java/org/openedx/core/domain/model/Block.kt index 2f1766ecb..460f283ba 100644 --- a/core/src/main/java/org/openedx/core/domain/model/Block.kt +++ b/core/src/main/java/org/openedx/core/domain/model/Block.kt @@ -7,6 +7,7 @@ import org.openedx.core.module.db.DownloadModel import org.openedx.core.module.db.DownloadedState import org.openedx.core.module.db.FileType import org.openedx.core.utils.VideoUtil +import java.util.Date data class Block( @@ -25,7 +26,9 @@ data class Block( val descendantsType: BlockType, val completion: Double, val containsGatedContent: Boolean = false, - val downloadModel: DownloadModel? = null + val downloadModel: DownloadModel? = null, + val assignmentProgress: AssignmentProgress?, + val due: Date? ) { val isDownloadable: Boolean get() { 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 0095b27f5..af901e153 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 @@ -36,7 +36,7 @@ data class AppColors( val inactiveButtonBackground: Color, val inactiveButtonText: Color, - val accessGreen: Color, + val successGreen: Color, val datesSectionBarPastDue: Color, val datesSectionBarToday: Color, 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 3235074b7..52f89305b 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 @@ -56,7 +56,7 @@ private val DarkColorPalette = AppColors( inactiveButtonBackground = dark_inactive_button_background, inactiveButtonText = dark_button_text, - accessGreen = dark_access_green, + successGreen = dark_success_green, datesSectionBarPastDue = dark_dates_section_bar_past_due, datesSectionBarToday = dark_dates_section_bar_today, @@ -130,7 +130,7 @@ private val LightColorPalette = AppColors( inactiveButtonBackground = light_inactive_button_background, inactiveButtonText = light_button_text, - accessGreen = light_access_green, + successGreen = light_success_green, datesSectionBarPastDue = light_dates_section_bar_past_due, datesSectionBarToday = light_dates_section_bar_today, 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 13a00cc81..f7ba7ab36 100644 --- a/core/src/openedx/org/openedx/core/ui/theme/Colors.kt +++ b/core/src/openedx/org/openedx/core/ui/theme/Colors.kt @@ -38,7 +38,7 @@ val light_info = Color(0xFF42AAFF) val light_rate_stars = Color(0xFFFFC94D) val light_inactive_button_background = Color(0xFFCCD4E0) val light_inactive_button_text = Color(0xFF3D4964) -val light_access_green = Color(0xFF23BCA0) +val light_success_green = Color(0xFF198571) val light_dates_section_bar_past_due = light_warning val light_dates_section_bar_today = light_info val light_dates_section_bar_this_week = light_text_primary_variant @@ -96,7 +96,7 @@ val dark_info = Color(0xFF0095FF) val dark_rate_stars = Color(0xFFFFC94D) val dark_inactive_button_background = Color(0xFFCCD4E0) val dark_inactive_button_text = Color(0xFF3D4964) -val dark_access_green = Color(0xFF23BCA0) +val dark_success_green = Color(0xFF198571) val dark_dates_section_bar_past_due = dark_warning val dark_dates_section_bar_today = dark_info val dark_dates_section_bar_this_week = dark_text_primary_variant diff --git a/course/src/main/java/org/openedx/course/presentation/outline/CourseOutlineScreen.kt b/course/src/main/java/org/openedx/course/presentation/outline/CourseOutlineScreen.kt index 734c74151..ea6a39d63 100644 --- a/course/src/main/java/org/openedx/course/presentation/outline/CourseOutlineScreen.kt +++ b/course/src/main/java/org/openedx/course/presentation/outline/CourseOutlineScreen.kt @@ -2,7 +2,6 @@ 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.animation.AnimatedVisibility import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -17,9 +16,7 @@ import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.items import androidx.compose.foundation.shape.CircleShape -import androidx.compose.material.Divider import androidx.compose.material.Icon import androidx.compose.material.LinearProgressIndicator import androidx.compose.material.MaterialTheme @@ -47,6 +44,7 @@ import androidx.compose.ui.unit.dp import androidx.fragment.app.FragmentManager import org.openedx.core.BlockType import org.openedx.core.UIMessage +import org.openedx.core.domain.model.AssignmentProgress import org.openedx.core.domain.model.Block import org.openedx.core.domain.model.BlockCounts import org.openedx.core.domain.model.CourseDatesBannerInfo @@ -69,9 +67,7 @@ import org.openedx.course.R import org.openedx.course.presentation.CourseRouter import org.openedx.course.presentation.ui.CourseDatesBanner import org.openedx.course.presentation.ui.CourseDatesBannerTablet -import org.openedx.course.presentation.ui.CourseExpandableChapterCard -import org.openedx.course.presentation.ui.CourseSectionCard -import org.openedx.course.presentation.ui.CourseSubSectionItem +import org.openedx.course.presentation.ui.CourseSection import java.io.File import java.util.Date import org.openedx.core.R as CoreR @@ -91,7 +87,6 @@ fun CourseOutlineScreen( CourseOutlineUI( windowSize = windowSize, uiState = uiState, - isCourseNestedListEnabled = courseOutlineViewModel.isCourseNestedListEnabled, uiMessage = uiMessage, onItemClick = { block -> courseOutlineViewModel.sequentialClickedEvent( @@ -131,44 +126,35 @@ fun CourseOutlineScreen( courseOutlineViewModel.resumeSectionBlock?.let { subSection -> courseOutlineViewModel.resumeCourseTappedEvent(subSection.id) courseOutlineViewModel.resumeVerticalBlock?.let { unit -> - if (courseOutlineViewModel.isCourseExpandableSectionsEnabled) { - courseRouter.navigateToCourseContainer( - fm = fragmentManager, - courseId = courseOutlineViewModel.courseId, - unitId = unit.id, - componentId = componentId, - mode = CourseViewMode.FULL - ) - } else { - courseRouter.navigateToCourseSubsections( - fragmentManager, - courseId = courseOutlineViewModel.courseId, - subSectionId = subSection.id, - mode = CourseViewMode.FULL, - unitId = unit.id, - componentId = componentId - ) - } + courseRouter.navigateToCourseContainer( + fm = fragmentManager, + courseId = courseOutlineViewModel.courseId, + unitId = unit.id, + componentId = componentId, + mode = CourseViewMode.FULL + ) } } }, - onDownloadClick = { - if (courseOutlineViewModel.isBlockDownloading(it.id)) { - courseRouter.navigateToDownloadQueue( - fm = fragmentManager, - courseOutlineViewModel.getDownloadableChildren(it.id) - ?: arrayListOf() - ) - } else if (courseOutlineViewModel.isBlockDownloaded(it.id)) { - courseOutlineViewModel.removeDownloadModels(it.id) - } else { - courseOutlineViewModel.saveDownloadModels( - context.externalCacheDir.toString() + - File.separator + - context - .getString(CoreR.string.app_name) - .replace(Regex("\\s"), "_"), it.id - ) + onDownloadClick = { blocksIds -> + blocksIds.forEach { blockId -> + if (courseOutlineViewModel.isBlockDownloading(blockId)) { + courseRouter.navigateToDownloadQueue( + fm = fragmentManager, + courseOutlineViewModel.getDownloadableChildren(blockId) + ?: arrayListOf() + ) + } else if (courseOutlineViewModel.isBlockDownloaded(blockId)) { + courseOutlineViewModel.removeDownloadModels(blockId) + } else { + courseOutlineViewModel.saveDownloadModels( + context.externalCacheDir.toString() + + File.separator + + context + .getString(CoreR.string.app_name) + .replace(Regex("\\s"), "_"), blockId + ) + } } }, onResetDatesClick = { @@ -190,13 +176,12 @@ fun CourseOutlineScreen( private fun CourseOutlineUI( windowSize: WindowSize, uiState: CourseOutlineUIState, - isCourseNestedListEnabled: Boolean, uiMessage: UIMessage?, onItemClick: (Block) -> Unit, onExpandClick: (Block) -> Unit, onSubSectionClick: (Block) -> Unit, onResumeClick: (String) -> Unit, - onDownloadClick: (Block) -> Unit, + onDownloadClick: (blockIds: List) -> Unit, onResetDatesClick: () -> Unit, onCertificateClick: (String) -> Unit, ) { @@ -309,75 +294,26 @@ private fun CourseOutlineUI( } } - if (isCourseNestedListEnabled) { - uiState.courseStructure.blockData.forEach { section -> - val courseSubSections = - uiState.courseSubSections[section.id] - val courseSectionsState = - uiState.courseSectionsState[section.id] - - item { - Column { - CourseExpandableChapterCard( - modifier = listPadding, - block = section, - onItemClick = onExpandClick, - arrowDegrees = if (courseSectionsState == true) -90f else 90f - ) - Divider() - } - } - - courseSubSections?.forEach { subSectionBlock -> - item { - Column { - AnimatedVisibility( - visible = courseSectionsState == true - ) { - Column { - val downloadsCount = - uiState.subSectionsDownloadsCount[subSectionBlock.id] - ?: 0 - - CourseSubSectionItem( - modifier = listPadding, - block = subSectionBlock, - downloadedState = uiState.downloadedState[subSectionBlock.id], - downloadsCount = downloadsCount, - onClick = onSubSectionClick, - onDownloadClick = onDownloadClick - ) - Divider() - } - } - } - } - } - } - return@LazyColumn + item { + Spacer(modifier = Modifier.height(12.dp)) } + uiState.courseStructure.blockData.forEach { section -> + val courseSubSections = + uiState.courseSubSections[section.id] + val courseSectionsState = + uiState.courseSectionsState[section.id] - items(uiState.courseStructure.blockData) { block -> - Column(listPadding) { - if (block.type == BlockType.CHAPTER) { - Text( - modifier = Modifier.padding( - top = 36.dp, - bottom = 8.dp - ), - text = block.displayName, - style = MaterialTheme.appTypography.titleMedium, - color = MaterialTheme.appColors.textPrimaryVariant - ) - } else { - CourseSectionCard( - block = block, - downloadedState = uiState.downloadedState[block.id], - onItemClick = onItemClick, - onDownloadClick = onDownloadClick - ) - Divider() - } + item { + CourseSection( + modifier = listPadding.padding(vertical = 4.dp), + block = section, + onItemClick = onExpandClick, + courseSectionsState = courseSectionsState, + courseSubSections = courseSubSections, + downloadedStateMap = uiState.downloadedState, + onSubSectionClick = onSubSectionClick, + onDownloadClick = onDownloadClick + ) } } } @@ -562,7 +498,6 @@ private fun CourseOutlineScreenPreview() { hasEnded = false ) ), - isCourseNestedListEnabled = true, uiMessage = null, onItemClick = {}, onExpandClick = {}, @@ -597,7 +532,6 @@ private fun CourseOutlineScreenTabletPreview() { hasEnded = false ) ), - isCourseNestedListEnabled = true, uiMessage = null, onItemClick = {}, onExpandClick = {}, @@ -619,6 +553,11 @@ private fun ResumeCoursePreview() { } } +private val mockAssignmentProgress = AssignmentProgress( + assignmentType = "Home", + numPointsEarned = 1f, + numPointsPossible = 3f +) private val mockChapterBlock = Block( id = "id", blockId = "blockId", @@ -634,7 +573,9 @@ private val mockChapterBlock = Block( descendants = emptyList(), descendantsType = BlockType.CHAPTER, completion = 0.0, - containsGatedContent = false + containsGatedContent = false, + assignmentProgress = mockAssignmentProgress, + due = Date() ) private val mockSequentialBlock = Block( id = "id", @@ -651,7 +592,9 @@ private val mockSequentialBlock = Block( descendants = emptyList(), descendantsType = BlockType.CHAPTER, completion = 0.0, - containsGatedContent = false + containsGatedContent = false, + assignmentProgress = mockAssignmentProgress, + due = Date() ) private val mockCourseStructure = CourseStructure( diff --git a/course/src/main/java/org/openedx/course/presentation/outline/CourseOutlineViewModel.kt b/course/src/main/java/org/openedx/course/presentation/outline/CourseOutlineViewModel.kt index 7a6e08b58..9d7e2ff23 100644 --- a/course/src/main/java/org/openedx/course/presentation/outline/CourseOutlineViewModel.kt +++ b/course/src/main/java/org/openedx/course/presentation/outline/CourseOutlineViewModel.kt @@ -59,7 +59,6 @@ class CourseOutlineViewModel( workerController, coreAnalytics ) { - val isCourseNestedListEnabled get() = config.isCourseNestedListEnabled() private val _uiState = MutableStateFlow(CourseOutlineUIState.Loading) val uiState: StateFlow @@ -74,8 +73,6 @@ class CourseOutlineViewModel( var resumeVerticalBlock: Block? = null private set - val isCourseExpandableSectionsEnabled get() = config.isCourseNestedListEnabled() - private val courseSubSections = mutableMapOf>() private val subSectionsDownloadsCount = mutableMapOf() val courseSubSectionUnit = mutableMapOf() @@ -228,17 +225,12 @@ class CourseOutlineViewModel( resultBlocks.add(block) block.descendants.forEach { descendant -> blocks.find { it.id == descendant }?.let { sequentialBlock -> - if (isCourseNestedListEnabled) { - courseSubSections.getOrPut(block.id) { mutableListOf() } - .add(sequentialBlock) - courseSubSectionUnit[sequentialBlock.id] = - sequentialBlock.getFirstDescendantBlock(blocks) - subSectionsDownloadsCount[sequentialBlock.id] = - sequentialBlock.getDownloadsCount(blocks) - - } else { - resultBlocks.add(sequentialBlock) - } + courseSubSections.getOrPut(block.id) { mutableListOf() } + .add(sequentialBlock) + courseSubSectionUnit[sequentialBlock.id] = + sequentialBlock.getFirstDescendantBlock(blocks) + subSectionsDownloadsCount[sequentialBlock.id] = + sequentialBlock.getDownloadsCount(blocks) addDownloadableChildrenForSequentialBlock(sequentialBlock) } } 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 297545117..2524832bd 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 @@ -5,14 +5,40 @@ import android.os.Bundle import android.view.LayoutInflater import android.view.ViewGroup import androidx.compose.foundation.clickable -import androidx.compose.foundation.layout.* +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.navigationBarsPadding +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.lazy.LazyColumn import androidx.compose.foundation.lazy.items -import androidx.compose.material.* +import androidx.compose.material.CircularProgressIndicator +import androidx.compose.material.Divider +import androidx.compose.material.Icon +import androidx.compose.material.IconButton +import androidx.compose.material.MaterialTheme +import androidx.compose.material.Scaffold +import androidx.compose.material.Surface +import androidx.compose.material.Text import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Close -import androidx.compose.runtime.* +import androidx.compose.material.rememberScaffoldState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue import androidx.compose.runtime.livedata.observeAsState +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -35,20 +61,29 @@ import org.koin.androidx.viewmodel.ext.android.viewModel import org.koin.core.parameter.parametersOf import org.openedx.core.BlockType import org.openedx.core.UIMessage +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.extension.serializable import org.openedx.core.module.db.DownloadedState import org.openedx.core.presentation.course.CourseViewMode -import org.openedx.core.ui.* +import org.openedx.core.ui.BackBtn +import org.openedx.core.ui.HandleUIMessage +import org.openedx.core.ui.WindowSize +import org.openedx.core.ui.WindowType +import org.openedx.core.ui.displayCutoutForLandscape +import org.openedx.core.ui.rememberWindowSize +import org.openedx.core.ui.statusBarsInset 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.core.ui.windowSizeValue import org.openedx.course.R import org.openedx.course.presentation.CourseRouter import org.openedx.course.presentation.ui.CardArrow import java.io.File +import java.util.Date class CourseSectionFragment : Fragment() { @@ -446,5 +481,7 @@ private val mockBlock = Block( descendants = emptyList(), descendantsType = BlockType.HTML, completion = 0.0, - containsGatedContent = false + containsGatedContent = false, + assignmentProgress = AssignmentProgress("", 1f, 2f), + due = Date() ) 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 f9f028c0f..68a39a304 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 @@ -1,8 +1,10 @@ package org.openedx.course.presentation.ui import android.content.res.Configuration +import androidx.compose.animation.AnimatedVisibility 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.foundation.BorderStroke import androidx.compose.foundation.Image @@ -77,6 +79,7 @@ import coil.compose.AsyncImage import coil.request.ImageRequest import org.jsoup.Jsoup 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.domain.model.Certificate @@ -102,6 +105,7 @@ 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.core.utils.TimeUtils import org.openedx.course.R import org.openedx.course.presentation.dates.mockedCourseBannerInfo import org.openedx.course.presentation.outline.getUnitBlockIcon @@ -193,107 +197,6 @@ fun CourseImageHeader( } } -@Composable -fun CourseSectionCard( - block: Block, - downloadedState: DownloadedState?, - onItemClick: (Block) -> Unit, - onDownloadClick: (Block) -> Unit -) { - val iconModifier = Modifier.size(24.dp) - - Column(Modifier.clickable { onItemClick(block) }) { - Row( - Modifier - .fillMaxWidth() - .height(80.dp) - .padding( - horizontal = 20.dp, - vertical = 24.dp - ), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.SpaceBetween - ) { - val completedIconPainter = - if (block.isCompleted()) painterResource(R.drawable.course_ic_task_alt) else painterResource( - R.drawable.ic_course_chapter_icon - ) - val completedIconColor = - if (block.isCompleted()) MaterialTheme.appColors.primary else MaterialTheme.appColors.onSurface - val completedIconDescription = if (block.isCompleted()) { - stringResource(id = R.string.course_accessibility_section_completed) - } else { - stringResource(id = R.string.course_accessibility_section_uncompleted) - } - Icon( - painter = completedIconPainter, - contentDescription = completedIconDescription, - tint = completedIconColor - ) - Spacer(modifier = Modifier.width(16.dp)) - Text( - modifier = Modifier.weight(1f), - text = block.displayName, - style = MaterialTheme.appTypography.titleSmall, - color = MaterialTheme.appColors.textPrimary, - maxLines = 1, - overflow = TextOverflow.Ellipsis - ) - Spacer(modifier = Modifier.width(16.dp)) - Row( - modifier = Modifier.fillMaxHeight(), - horizontalArrangement = Arrangement.spacedBy(24.dp), - verticalAlignment = Alignment.CenterVertically - ) { - if (downloadedState == DownloadedState.DOWNLOADED || downloadedState == DownloadedState.NOT_DOWNLOADED) { - val downloadIconPainter = if (downloadedState == DownloadedState.DOWNLOADED) { - painterResource(id = R.drawable.course_ic_remove_download) - } else { - painterResource(id = R.drawable.course_ic_start_download) - } - val downloadIconDescription = - if (downloadedState == DownloadedState.DOWNLOADED) { - stringResource(id = R.string.course_accessibility_remove_course_section) - } else { - stringResource(id = R.string.course_accessibility_download_course_section) - } - IconButton(modifier = iconModifier, - onClick = { onDownloadClick(block) }) { - Icon( - painter = downloadIconPainter, - contentDescription = downloadIconDescription, - tint = MaterialTheme.appColors.textPrimary - ) - } - } else if (downloadedState != null) { - Box(contentAlignment = Alignment.Center) { - if (downloadedState == DownloadedState.DOWNLOADING || downloadedState == DownloadedState.WAITING) { - CircularProgressIndicator( - modifier = Modifier.size(34.dp), - backgroundColor = Color.LightGray, - strokeWidth = 2.dp, - color = MaterialTheme.appColors.primary - ) - } - IconButton( - modifier = iconModifier.padding(top = 2.dp), - onClick = { onDownloadClick(block) }) { - Icon( - imageVector = Icons.Filled.Close, - contentDescription = stringResource(id = R.string.course_accessibility_stop_downloading_course_section), - tint = MaterialTheme.appColors.error - ) - } - } - } - CardArrow( - degrees = 0f - ) - } - } - } -} - @Composable fun OfflineQueueCard( downloadModel: DownloadModel, @@ -371,7 +274,7 @@ fun CardArrow( ) { Icon( imageVector = Icons.Filled.ChevronRight, - tint = MaterialTheme.appColors.primary, + tint = MaterialTheme.appColors.textDark, contentDescription = "Expandable Arrow", modifier = Modifier.rotate(degrees), ) @@ -706,81 +609,173 @@ fun VideoSubtitles( } @Composable -fun CourseExpandableChapterCard( - modifier: Modifier, +fun CourseSection( + modifier: Modifier = Modifier, block: Block, onItemClick: (Block) -> Unit, - arrowDegrees: Float = 0f + courseSectionsState: Boolean?, + courseSubSections: List?, + downloadedStateMap: Map, + onSubSectionClick: (Block) -> Unit, + onDownloadClick: (blocksIds: List) -> Unit ) { - Column(modifier = Modifier + val arrowRotation by animateFloatAsState( + targetValue = if (courseSectionsState == true) -90f else 90f, label = "" + ) + val subsectionsDownloadedStates = downloadedStateMap.filterKeys { key -> + key in (courseSubSections?.map { it.id } ?: emptyList()) + }.values.toList() + val downloadedState = + if (subsectionsDownloadedStates.isNotEmpty() && subsectionsDownloadedStates.all { it.isDownloaded }) { + DownloadedState.DOWNLOADED + } else if (subsectionsDownloadedStates.firstOrNull { it.isWaitingOrDownloading } != null) { + DownloadedState.DOWNLOADING + } else if (subsectionsDownloadedStates.isNotEmpty()) { + DownloadedState.NOT_DOWNLOADED + } else { + null + } + + Column(modifier = modifier + .clip(MaterialTheme.appShapes.cardShape) .clickable { onItemClick(block) } - .background(if (block.isCompleted()) MaterialTheme.appColors.surface else Color.Transparent) + .background(MaterialTheme.appColors.cardViewBackground) + .border( + 1.dp, + MaterialTheme.appColors.cardViewBorder, + MaterialTheme.appShapes.cardShape + ) ) { - Row( - modifier - .fillMaxWidth() - .height(60.dp) - .padding( - vertical = 8.dp - ), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.SpaceBetween - ) { - if (block.isCompleted()) { - val completedIconPainter = painterResource(R.drawable.course_ic_task_alt) - val completedIconColor = MaterialTheme.appColors.primary - val completedIconDescription = - stringResource(id = R.string.course_accessibility_section_completed) - - Icon( - painter = completedIconPainter, - contentDescription = completedIconDescription, - tint = completedIconColor + CourseExpandableChapterCard( + block = block, + arrowDegrees = arrowRotation, + downloadedState = downloadedState, + onDownloadClick = { + onDownloadClick(downloadedStateMap.keys.toList()) + } + ) + courseSubSections?.forEach { subSectionBlock -> + AnimatedVisibility( + visible = courseSectionsState == true + ) { + CourseSubSectionItem( + block = subSectionBlock, + onClick = onSubSectionClick ) - Spacer(modifier = Modifier.width(16.dp)) } - Text( - modifier = Modifier.weight(1f), - text = block.displayName, - style = MaterialTheme.appTypography.titleSmall, - color = MaterialTheme.appColors.textPrimary, - maxLines = 1, - overflow = TextOverflow.Ellipsis + } + } +} + +@Composable +fun CourseExpandableChapterCard( + modifier: Modifier = Modifier, + block: Block, + arrowDegrees: Float = 0f, + downloadedState: DownloadedState?, + onDownloadClick: () -> Unit +) { + val iconModifier = Modifier.size(24.dp) + Row( + modifier + .fillMaxWidth() + .height(48.dp) + .padding(vertical = 8.dp) + .padding(start = 16.dp, end = 16.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(4.dp) + ) { + CardArrow(degrees = arrowDegrees) + if (block.isCompleted()) { + val completedIconPainter = painterResource(R.drawable.course_ic_task_alt) + val completedIconColor = MaterialTheme.appColors.successGreen + val completedIconDescription = stringResource(id = R.string.course_accessibility_section_completed) + + Icon( + painter = completedIconPainter, + contentDescription = completedIconDescription, + tint = completedIconColor ) - Spacer(modifier = Modifier.width(16.dp)) - CardArrow(degrees = arrowDegrees) + } + Text( + modifier = Modifier.weight(1f), + text = block.displayName, + style = MaterialTheme.appTypography.titleSmall, + color = MaterialTheme.appColors.textPrimary, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + Row( + modifier = Modifier.fillMaxHeight(), + verticalAlignment = Alignment.CenterVertically + ) { + if (downloadedState == DownloadedState.DOWNLOADED || downloadedState == DownloadedState.NOT_DOWNLOADED) { + val downloadIconPainter = if (downloadedState == DownloadedState.DOWNLOADED) { + painterResource(id = R.drawable.course_ic_remove_download) + } else { + painterResource(id = R.drawable.course_ic_start_download) + } + val downloadIconDescription = + if (downloadedState == DownloadedState.DOWNLOADED) { + stringResource(id = R.string.course_accessibility_remove_course_section) + } else { + stringResource(id = R.string.course_accessibility_download_course_section) + } + IconButton(modifier = iconModifier, + onClick = { onDownloadClick() }) { + Icon( + painter = downloadIconPainter, + contentDescription = downloadIconDescription, + tint = MaterialTheme.appColors.primary + ) + } + } else if (downloadedState != null) { + Box(contentAlignment = Alignment.Center) { + if (downloadedState == DownloadedState.DOWNLOADING || downloadedState == DownloadedState.WAITING) { + CircularProgressIndicator( + modifier = Modifier.size(28.dp), + backgroundColor = Color.LightGray, + strokeWidth = 2.dp, + color = MaterialTheme.appColors.primary + ) + } + IconButton( + modifier = iconModifier.padding(2.dp), + onClick = { onDownloadClick() }) { + Text( + modifier = Modifier + .padding(bottom = 4.dp), + text = "i", + style = MaterialTheme.appTypography.titleMedium, + color = MaterialTheme.appColors.primary + ) + } + } + } } } } @Composable fun CourseSubSectionItem( - modifier: Modifier, + modifier: Modifier = Modifier, block: Block, - downloadedState: DownloadedState?, - downloadsCount: Int, - onClick: (Block) -> Unit, - onDownloadClick: (Block) -> Unit + onClick: (Block) -> Unit ) { val icon = - if (block.isCompleted()) painterResource(R.drawable.course_ic_task_alt) else painterResource( - R.drawable.ic_course_chapter_icon - ) + if (block.isCompleted()) painterResource(R.drawable.course_ic_task_alt) else painterResource(R.drawable.ic_course_chapter_icon) val iconColor = - if (block.isCompleted()) MaterialTheme.appColors.primary else MaterialTheme.appColors.onSurface + if (block.isCompleted()) MaterialTheme.appColors.successGreen else MaterialTheme.appColors.onSurface - val iconModifier = Modifier.size(24.dp) - - Column(Modifier - .clickable { onClick(block) } - .background(if (block.isCompleted()) MaterialTheme.appColors.surface else Color.Transparent) + val due = block.due?.let { TimeUtils.getCourseFormattedDate(LocalContext.current, it) } + val isAssignmentEnable = !block.isCompleted() && block.assignmentProgress != null && !due.isNullOrEmpty() + Column( + modifier = modifier + .fillMaxWidth() + .clickable { onClick(block) } + .padding(horizontal = 16.dp, vertical = 12.dp) ) { Row( - modifier - .fillMaxWidth() - .height(60.dp) - .padding(vertical = 16.dp) - .padding(start = 20.dp), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.SpaceBetween ) { @@ -789,7 +784,7 @@ fun CourseSubSectionItem( contentDescription = null, tint = iconColor ) - Spacer(modifier = Modifier.width(16.dp)) + Spacer(modifier = Modifier.width(4.dp)) Text( modifier = Modifier.weight(1f), text = block.displayName, @@ -799,63 +794,25 @@ fun CourseSubSectionItem( maxLines = 1 ) Spacer(modifier = Modifier.width(16.dp)) - Row( - modifier = Modifier.fillMaxHeight(), - horizontalArrangement = Arrangement.spacedBy(if (downloadsCount > 0) 8.dp else 24.dp), - verticalAlignment = Alignment.CenterVertically - ) { - if (downloadedState == DownloadedState.DOWNLOADED || downloadedState == DownloadedState.NOT_DOWNLOADED) { - val downloadIconPainter = if (downloadedState == DownloadedState.DOWNLOADED) { - painterResource(id = R.drawable.course_ic_remove_download) - } else { - painterResource(id = R.drawable.course_ic_start_download) - } - val downloadIconDescription = - if (downloadedState == DownloadedState.DOWNLOADED) { - stringResource(id = R.string.course_accessibility_remove_course_section) - } else { - stringResource(id = R.string.course_accessibility_download_course_section) - } - IconButton(modifier = iconModifier, - onClick = { onDownloadClick(block) }) { - Icon( - painter = downloadIconPainter, - contentDescription = downloadIconDescription, - tint = MaterialTheme.appColors.textPrimary - ) - } - } else if (downloadedState != null) { - Box(contentAlignment = Alignment.Center) { - if (downloadedState == DownloadedState.DOWNLOADING || downloadedState == DownloadedState.WAITING) { - CircularProgressIndicator( - modifier = Modifier.size(28.dp), - backgroundColor = Color.LightGray, - strokeWidth = 2.dp, - color = MaterialTheme.appColors.primary - ) - } - IconButton( - modifier = iconModifier.padding(2.dp), - onClick = { onDownloadClick(block) }) { - Text( - modifier = Modifier - .padding(bottom = 4.dp), - text = "i", - style = MaterialTheme.appTypography.titleMedium, - color = MaterialTheme.appColors.primary - ) - } - } - } - if (downloadsCount > 0) { - Text( - text = downloadsCount.toString(), - style = MaterialTheme.appTypography.titleSmall, - color = MaterialTheme.appColors.textPrimary - ) - } + if (isAssignmentEnable) { + Icon( + imageVector = Icons.Filled.ChevronRight, + tint = MaterialTheme.appColors.onSurface, + contentDescription = null + ) } } + + if (isAssignmentEnable) { + val assignmentString = + "${block.assignmentProgress?.assignmentType} - $due - ${block.assignmentProgress?.numPointsEarned?.toInt()} / ${block.assignmentProgress?.numPointsPossible?.toInt()}" + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = assignmentString, + style = MaterialTheme.appTypography.bodySmall, + color = MaterialTheme.appColors.textPrimary + ) + } } } @@ -1274,22 +1231,6 @@ private fun SequentialItemPreview() { } } -@Preview(uiMode = Configuration.UI_MODE_NIGHT_NO) -@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES) -@Composable -private fun CourseChapterItemPreview() { - OpenEdXTheme { - Surface(color = MaterialTheme.appColors.background) { - CourseSectionCard( - mockChapterBlock, - DownloadedState.DOWNLOADED, - onItemClick = {}, - onDownloadClick = {} - ) - } - } -} - @Preview(uiMode = Configuration.UI_MODE_NIGHT_NO) @Preview(uiMode = Configuration.UI_MODE_NIGHT_YES) @Composable @@ -1412,5 +1353,7 @@ private val mockChapterBlock = Block( descendants = emptyList(), descendantsType = BlockType.CHAPTER, completion = 0.0, - containsGatedContent = false + containsGatedContent = false, + assignmentProgress = AssignmentProgress("", 1f, 2f), + due = Date() ) diff --git a/course/src/main/java/org/openedx/course/presentation/ui/CourseVideosUI.kt b/course/src/main/java/org/openedx/course/presentation/ui/CourseVideosUI.kt index b4716a67d..8e638785a 100644 --- a/course/src/main/java/org/openedx/course/presentation/ui/CourseVideosUI.kt +++ b/course/src/main/java/org/openedx/course/presentation/ui/CourseVideosUI.kt @@ -1,7 +1,6 @@ package org.openedx.course.presentation.ui import android.content.res.Configuration -import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.core.LinearEasing import androidx.compose.animation.core.animateFloatAsState import androidx.compose.animation.core.tween @@ -19,7 +18,6 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.items import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll import androidx.compose.material.AlertDialog @@ -59,6 +57,7 @@ import androidx.fragment.app.FragmentManager import org.openedx.core.AppDataConstants import org.openedx.core.BlockType import org.openedx.core.UIMessage +import org.openedx.core.domain.model.AssignmentProgress import org.openedx.core.domain.model.Block import org.openedx.core.domain.model.BlockCounts import org.openedx.core.domain.model.CourseStructure @@ -101,7 +100,6 @@ fun CourseVideosScreen( uiState = uiState, uiMessage = uiMessage, courseTitle = courseVideoViewModel.courseTitle, - isCourseNestedListEnabled = courseVideoViewModel.isCourseNestedListEnabled, videoSettings = videoSettings, onItemClick = { block -> courseRouter.navigateToCourseSubsections( @@ -128,23 +126,25 @@ fun CourseVideosScreen( ) } }, - onDownloadClick = { - if (courseVideoViewModel.isBlockDownloading(it.id)) { - courseRouter.navigateToDownloadQueue( - fm = fragmentManager, - courseVideoViewModel.getDownloadableChildren(it.id) - ?: arrayListOf() - ) - } else if (courseVideoViewModel.isBlockDownloaded(it.id)) { - courseVideoViewModel.removeDownloadModels(it.id) - } else { - courseVideoViewModel.saveDownloadModels( - context.externalCacheDir.toString() + - File.separator + - context - .getString(org.openedx.core.R.string.app_name) - .replace(Regex("\\s"), "_"), it.id - ) + onDownloadClick = { blocksIds -> + blocksIds.forEach { blockId -> + if (courseVideoViewModel.isBlockDownloading(blockId)) { + courseRouter.navigateToDownloadQueue( + fm = fragmentManager, + courseVideoViewModel.getDownloadableChildren(blockId) + ?: arrayListOf() + ) + } else if (courseVideoViewModel.isBlockDownloaded(blockId)) { + courseVideoViewModel.removeDownloadModels(blockId) + } else { + courseVideoViewModel.saveDownloadModels( + context.externalCacheDir.toString() + + File.separator + + context + .getString(org.openedx.core.R.string.app_name) + .replace(Regex("\\s"), "_"), blockId + ) + } } }, onDownloadAllClick = { isAllBlocksDownloadedOrDownloading -> @@ -185,12 +185,11 @@ private fun CourseVideosUI( uiState: CourseVideosUIState, uiMessage: UIMessage?, courseTitle: String, - isCourseNestedListEnabled: Boolean, videoSettings: VideoSettings, onItemClick: (Block) -> Unit, onExpandClick: (Block) -> Unit, onSubSectionClick: (Block) -> Unit, - onDownloadClick: (Block) -> Unit, + onDownloadClick: (blocksIds: List) -> Unit, onDownloadAllClick: (Boolean) -> Unit, onDownloadQueueClick: () -> Unit, onVideoDownloadQualityClick: () -> Unit @@ -305,88 +304,26 @@ private fun CourseVideosUI( } } - if (isCourseNestedListEnabled) { - uiState.courseStructure.blockData.forEach { section -> - val courseSubSections = uiState.courseSubSections[section.id] - val courseSectionsState = uiState.courseSectionsState[section.id] - - item { - Column { - CourseExpandableChapterCard( - modifier = listPadding, - block = section, - onItemClick = onExpandClick, - arrowDegrees = if (courseSectionsState == true) -90f else 90f - ) - Divider() - } - } - - courseSubSections?.forEach { subSectionBlock -> - item { - Column { - AnimatedVisibility( - visible = courseSectionsState == true - ) { - Column { - val downloadsCount = - uiState.subSectionsDownloadsCount[subSectionBlock.id] - ?: 0 - - CourseSubSectionItem( - modifier = listPadding, - block = subSectionBlock, - downloadedState = uiState.downloadedState[subSectionBlock.id], - downloadsCount = downloadsCount, - onClick = onSubSectionClick, - onDownloadClick = { block -> - if (uiState.downloadedState[block.id]?.isDownloaded == true) { - deleteDownloadBlock = - block - - } else { - onDownloadClick(block) - } - } - ) - Divider() - } - } - } - } - } - } - return@LazyColumn + item { + Spacer(modifier = Modifier.height(12.dp)) } + uiState.courseStructure.blockData.forEach { section -> + val courseSubSections = + uiState.courseSubSections[section.id] + val courseSectionsState = + uiState.courseSectionsState[section.id] - items(uiState.courseStructure.blockData) { block -> - Column(listPadding) { - if (block.type == BlockType.CHAPTER) { - Text( - modifier = Modifier.padding( - top = 36.dp, - bottom = 8.dp - ), - text = block.displayName, - style = MaterialTheme.appTypography.titleMedium, - color = MaterialTheme.appColors.textPrimaryVariant - ) - } else { - CourseSectionCard( - block = block, - downloadedState = uiState.downloadedState[block.id], - onItemClick = onItemClick, - onDownloadClick = { block -> - if (uiState.downloadedState[block.id]?.isDownloaded == true) { - deleteDownloadBlock = block - - } else { - onDownloadClick(block) - } - } - ) - Divider() - } + item { + CourseSection( + modifier = listPadding.padding(vertical = 4.dp), + block = section, + onItemClick = onExpandClick, + courseSectionsState = courseSectionsState, + courseSubSections = courseSubSections, + downloadedStateMap = uiState.downloadedState, + onSubSectionClick = onSubSectionClick, + onDownloadClick = onDownloadClick + ) } } } @@ -508,7 +445,7 @@ private fun CourseVideosUI( TextButton( onClick = { deleteDownloadBlock?.let { block -> - onDownloadClick(block) + onDownloadClick(listOf(block.id)) } deleteDownloadBlock = null } @@ -718,7 +655,6 @@ private fun CourseVideosScreenPreview() { ) ), courseTitle = "", - isCourseNestedListEnabled = false, onItemClick = { }, onExpandClick = { }, onSubSectionClick = { }, @@ -743,7 +679,6 @@ private fun CourseVideosScreenEmptyPreview() { "This course does not include any videos." ), courseTitle = "", - isCourseNestedListEnabled = false, onItemClick = { }, onExpandClick = { }, onSubSectionClick = { }, @@ -779,7 +714,6 @@ private fun CourseVideosScreenTabletPreview() { ) ), courseTitle = "", - isCourseNestedListEnabled = false, onItemClick = { }, onExpandClick = { }, onSubSectionClick = { }, @@ -792,6 +726,11 @@ private fun CourseVideosScreenTabletPreview() { } } +private val mockAssignmentProgress = AssignmentProgress( + assignmentType = "Home", + numPointsEarned = 1f, + numPointsPossible = 3f +) private val mockChapterBlock = Block( id = "id", @@ -808,7 +747,9 @@ private val mockChapterBlock = Block( descendants = emptyList(), descendantsType = BlockType.CHAPTER, completion = 0.0, - containsGatedContent = false + containsGatedContent = false, + assignmentProgress = mockAssignmentProgress, + due = Date() ) private val mockSequentialBlock = Block( @@ -826,7 +767,9 @@ private val mockSequentialBlock = Block( descendants = emptyList(), descendantsType = BlockType.SEQUENTIAL, completion = 0.0, - containsGatedContent = false + containsGatedContent = false, + assignmentProgress = mockAssignmentProgress, + due = Date() ) private val mockCourseStructure = CourseStructure( 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 f5e9be934..19940fb2c 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 @@ -50,9 +50,6 @@ class CourseVideoViewModel( workerController, coreAnalytics ) { - - val isCourseNestedListEnabled get() = config.isCourseNestedListEnabled() - private val _uiState = MutableStateFlow(CourseVideosUIState.Loading) val uiState: StateFlow get() = _uiState.asStateFlow() @@ -197,15 +194,10 @@ class CourseVideoViewModel( resultBlocks.add(block) block.descendants.forEach { descendant -> blocks.find { it.id == descendant }?.let { - if (isCourseNestedListEnabled) { - courseSubSections.getOrPut(block.id) { mutableListOf() } - .add(it) - courseSubSectionUnit[it.id] = it.getFirstDescendantBlock(blocks) - subSectionsDownloadsCount[it.id] = it.getDownloadsCount(blocks) - - } else { - resultBlocks.add(it) - } + courseSubSections.getOrPut(block.id) { mutableListOf() } + .add(it) + courseSubSectionUnit[it.id] = it.getFirstDescendantBlock(blocks) + subSectionsDownloadsCount[it.id] = it.getDownloadsCount(blocks) addDownloadableChildrenForSequentialBlock(it) } } diff --git a/course/src/main/res/drawable/course_ic_start_download.xml b/course/src/main/res/drawable/course_ic_start_download.xml index e56223200..67d565694 100644 --- a/course/src/main/res/drawable/course_ic_start_download.xml +++ b/course/src/main/res/drawable/course_ic_start_download.xml @@ -3,29 +3,7 @@ android:height="24dp" android:viewportWidth="24" android:viewportHeight="24"> - - - - - - + diff --git a/profile/src/main/java/org/openedx/profile/presentation/settings/SettingsScreenUI.kt b/profile/src/main/java/org/openedx/profile/presentation/settings/SettingsScreenUI.kt index f5c0a7bc5..ea6471330 100644 --- a/profile/src/main/java/org/openedx/profile/presentation/settings/SettingsScreenUI.kt +++ b/profile/src/main/java/org/openedx/profile/presentation/settings/SettingsScreenUI.kt @@ -522,7 +522,7 @@ private fun AppVersionItemAppToDate(versionName: String) { ), painter = painterResource(id = R.drawable.core_ic_check), contentDescription = null, - tint = MaterialTheme.appColors.accessGreen + tint = MaterialTheme.appColors.successGreen ) Text( modifier = Modifier.testTag("txt_up_to_date"), From 78628f5ffe3f87380cc5aca8a058e6d7a42fc174 Mon Sep 17 00:00:00 2001 From: PavloNetrebchuk Date: Fri, 24 May 2024 11:06:07 +0300 Subject: [PATCH 03/18] feat: New download icons --- .../course/presentation/ui/CourseUI.kt | 109 +++++------------- .../res/drawable/course_download_waiting.png | Bin 0 -> 1945 bytes 2 files changed, 26 insertions(+), 83 deletions(-) create mode 100644 course/src/main/res/drawable/course_download_waiting.png 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 68a39a304..4b192251c 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 @@ -47,6 +47,7 @@ import androidx.compose.material.Text import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.ChevronRight import androidx.compose.material.icons.filled.Close +import androidx.compose.material.icons.filled.CloudDone import androidx.compose.material.icons.filled.Home import androidx.compose.material.icons.filled.TaskAlt import androidx.compose.material.rememberScaffoldState @@ -62,6 +63,7 @@ import androidx.compose.ui.draw.alpha import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.rotate import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.vector.rememberVectorPainter import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalConfiguration import androidx.compose.ui.platform.LocalContext @@ -84,10 +86,6 @@ import org.openedx.core.domain.model.Block import org.openedx.core.domain.model.BlockCounts import org.openedx.core.domain.model.Certificate import org.openedx.core.domain.model.CourseDatesBannerInfo -import org.openedx.core.domain.model.CourseSharingUtmParameters -import org.openedx.core.domain.model.CoursewareAccess -import org.openedx.core.domain.model.EnrolledCourse -import org.openedx.core.domain.model.EnrolledCourseData import org.openedx.core.extension.isLinkValid import org.openedx.core.extension.nonZero import org.openedx.core.extension.toFileSize @@ -710,44 +708,55 @@ fun CourseExpandableChapterCard( verticalAlignment = Alignment.CenterVertically ) { if (downloadedState == DownloadedState.DOWNLOADED || downloadedState == DownloadedState.NOT_DOWNLOADED) { - val downloadIconPainter = if (downloadedState == DownloadedState.DOWNLOADED) { - painterResource(id = R.drawable.course_ic_remove_download) - } else { - painterResource(id = R.drawable.course_ic_start_download) - } + val downloadIconPainter = + if (downloadedState == DownloadedState.DOWNLOADED) { + rememberVectorPainter(Icons.Default.CloudDone) + } else { + painterResource(id = R.drawable.course_ic_start_download) + } val downloadIconDescription = if (downloadedState == DownloadedState.DOWNLOADED) { stringResource(id = R.string.course_accessibility_remove_course_section) } else { stringResource(id = R.string.course_accessibility_download_course_section) } + val downloadIconTint = + if (downloadedState == DownloadedState.DOWNLOADED) { + MaterialTheme.appColors.successGreen + } else { + MaterialTheme.appColors.primary + } IconButton(modifier = iconModifier, onClick = { onDownloadClick() }) { Icon( painter = downloadIconPainter, contentDescription = downloadIconDescription, - tint = MaterialTheme.appColors.primary + tint = downloadIconTint ) } } else if (downloadedState != null) { Box(contentAlignment = Alignment.Center) { - if (downloadedState == DownloadedState.DOWNLOADING || downloadedState == DownloadedState.WAITING) { + if (downloadedState == DownloadedState.DOWNLOADING) { CircularProgressIndicator( modifier = Modifier.size(28.dp), backgroundColor = Color.LightGray, strokeWidth = 2.dp, color = MaterialTheme.appColors.primary ) + } else if (downloadedState == DownloadedState.WAITING) { + Icon( + painter = painterResource(id = R.drawable.course_download_waiting), + contentDescription = stringResource(id = R.string.course_accessibility_stop_downloading_course_section), + tint = MaterialTheme.appColors.error + ) } IconButton( modifier = iconModifier.padding(2.dp), onClick = { onDownloadClick() }) { - Text( - modifier = Modifier - .padding(bottom = 4.dp), - text = "i", - style = MaterialTheme.appTypography.titleMedium, - color = MaterialTheme.appColors.primary + Icon( + imageVector = Icons.Filled.Close, + contentDescription = stringResource(id = R.string.course_accessibility_stop_downloading_course_section), + tint = MaterialTheme.appColors.error ) } } @@ -816,36 +825,6 @@ fun CourseSubSectionItem( } } -@Composable -fun CourseToolbar( - title: String, - onBackClick: () -> Unit -) { - OpenEdXTheme { - Box( - modifier = Modifier - .fillMaxWidth() - .displayCutoutForLandscape() - .zIndex(1f) - .statusBarsPadding(), - contentAlignment = Alignment.CenterStart - ) { - BackBtn { onBackClick() } - Text( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 56.dp), - text = title, - color = MaterialTheme.appColors.textPrimary, - style = MaterialTheme.appTypography.titleMedium, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - textAlign = TextAlign.Center - ) - } - } -} - @Composable fun CourseUnitToolbar( title: String, @@ -1302,42 +1281,6 @@ private fun OfflineQueueCardPreview() { } } -private val mockCourse = EnrolledCourse( - auditAccessExpires = Date(), - created = "created", - certificate = Certificate(""), - mode = "mode", - isActive = true, - course = EnrolledCourseData( - id = "id", - name = "Course name", - number = "", - org = "Org", - start = Date(), - startDisplay = "", - startType = "", - end = Date(), - dynamicUpgradeDeadline = "", - subscriptionId = "", - coursewareAccess = CoursewareAccess( - true, - "", - "", - "", - "", - "" - ), - media = null, - courseImage = "", - courseAbout = "", - courseSharingUtmParameters = CourseSharingUtmParameters("", ""), - courseUpdates = "", - courseHandouts = "", - discussionUrl = "", - videoOutline = "", - isSelfPaced = false - ) -) private val mockChapterBlock = Block( id = "id", blockId = "blockId", diff --git a/course/src/main/res/drawable/course_download_waiting.png b/course/src/main/res/drawable/course_download_waiting.png new file mode 100644 index 0000000000000000000000000000000000000000..c4a04af69630a5f614eb1b71780d4554038f2043 GIT binary patch literal 1945 zcmV;K2WI$*P)upfI!Tjv0M#aRK#3J5CzRsgI3x&nt4Bw0b+*J4iG66mAl9k}N} z9vWNI`;8?lO#E%-@Z&4#3UAI>-VUe&D4k&MJ`6h!JC9`M>?@`A9{k+unVwTj9$V`0l|Q1C z`kDEViI3#Dx3>C=LMa409S$3lFSIX%z>L8%)9K8#D<3^m8OI?{DRX_uBMXGcsI!4T zL<};)vL0WutNpo{m0?{^a~G#Sg+-0dp?&R*~+OYjcs|pZzq`i3lHb&A!;hc zT_N1C|7o~O1*33+1!!2(o2RG4rYXaBQ;1^0=|8%zwkZ#UV`c5fwNW_1f~C;2#pbmdBQ5x&2eiV^&Y3>`wfJwC(MdXKu&9 zRilRNefS!bvgw0U_0PPXeC^~YMg`7@US&3YaKQct@9_QE=ylq2iCH$rZ}*nEeSfRD zLj?hKxvpwq7Geon+P(txz5mfW#8@aNIM2gqKsewD1skof^5+k>T5oL6_x~Oquv^~0 zodh_v>}yS#;V5pYjQ~Pdpv_OxeO>Q3!p)H7?!he=}T21Uva*n6dcK03_ZWIDz)J?E3G%QF+frk`~~ZA*3o zGw(Y4!A${uGp&vYirPZ7ZkD<-Zu)AJnkz)RIJb1B0?5=il|IH0nfEYR|1V|vy)abZ zIj-8tvvGb4*!Eyx5%Y>87nhuA9=IDex-7&BVIbN#O@m@WE`r4y#rI_*RtWT87}%){ ze0*d5mjiU;&yT}4Eh*6VDl@6o26RY}m}$)c8kp#CWeZ&eVC&8n>ktX&!r z!Q^;gx3H9??yV9;;{V;Ad$}x~Sb;s76&fTTm*v=_6^9sFuAC)hW_h5ArcIb;Dy)w` zVvz}r3`MGys|;{Se9CG&-ngJ6oAISajT$v-tO;13$d&UDK3I_ELr)p_Gps9@fWyqi zchqor=FkFLrwI0UeDeQdSh0!C4kEB9ubZ|)D`G}fq5qehKYgIPrlF0o0wp-=s~si@@){T6l%lV?vvurTUOEJV9X&C4Qi z7tiW6yQ~P@poVjaOaoTj6OrpU^dXA^8{ph4f?0zM5oMFl^h`cp(h9>EhNE;OUK>$z z^6NH|E{{e^y|q;)1)kEX!|W!ZfmqD&d(4naM`TB1m0h6XPBr>>=9Y-qxKbw4y+$4aP{Ou*HO%#GF_G-__}+<`mGv zf>5uPB`l^W%xoO|B6KRex7BPdEKs{_&gf7^2y~4sfXnu}x}E0_FokE&7d~|qpDaTZ zTN3EAtN0LOxK96b5e&yCL{S7X%Pfj*iHH^toxuy=^Ey0dCYZh(XXZE?P35rBWr>$e z24`tbC}TpDZq|A-#Tp?-T4c0mDV2CrR(rqQZ4Ib`z@CLIgzWce;P~@KWX;n>Q_9)M z8oQ0YBKkI#G|)E+lyMD12z}n%;I14>NB6f{Rz?uN@*oWKvZ;N)208%#+<7X09{Qp; z^+i`h^gj&WNU!c-8Rp$eauX~o0{wW$kku>7MsO2tBvH!UJ2R)WeE05z<&yqcFx6kf zsjq_>UWTTnoGbm()V*w0g$G86E+^(ZZmpATpl9;)KmdZsPaShRx(_`n7^U1$w;(F6 zXG)(;>}B9@YD(X5Q`PF7G8j(b1cUdFN&E;fxXz{z=Uh|z?9MT3TcyZ_g%j-lFRdeJ zDU(-J&7iFALTP?kX;lTSi7XyW0(o?{lV4}UP~HBz^QtRo?}?sEd Date: Tue, 28 May 2024 17:56:29 +0300 Subject: [PATCH 04/18] feat: show CourseContainerFragment if COURSE_NESTED_LIST_ENABLED false --- .../outline/CourseOutlineScreen.kt | 42 +++++++++---------- .../outline/CourseOutlineViewModel.kt | 1 + 2 files changed, 21 insertions(+), 22 deletions(-) diff --git a/course/src/main/java/org/openedx/course/presentation/outline/CourseOutlineScreen.kt b/course/src/main/java/org/openedx/course/presentation/outline/CourseOutlineScreen.kt index ea6a39d63..e1652c3eb 100644 --- a/course/src/main/java/org/openedx/course/presentation/outline/CourseOutlineScreen.kt +++ b/course/src/main/java/org/openedx/course/presentation/outline/CourseOutlineScreen.kt @@ -88,18 +88,6 @@ fun CourseOutlineScreen( windowSize = windowSize, uiState = uiState, uiMessage = uiMessage, - onItemClick = { block -> - courseOutlineViewModel.sequentialClickedEvent( - block.blockId, - block.displayName - ) - courseRouter.navigateToCourseSubsections( - fm = fragmentManager, - courseId = courseOutlineViewModel.courseId, - subSectionId = block.id, - mode = CourseViewMode.FULL - ) - }, onExpandClick = { block -> if (courseOutlineViewModel.switchCourseSections(block.id)) { courseOutlineViewModel.sequentialClickedEvent( @@ -109,15 +97,28 @@ fun CourseOutlineScreen( } }, onSubSectionClick = { subSectionBlock -> - courseOutlineViewModel.courseSubSectionUnit[subSectionBlock.id]?.let { unit -> - courseOutlineViewModel.logUnitDetailViewedEvent( - unit.blockId, - unit.displayName + if (courseOutlineViewModel.isCourseNestedListEnabled) { + courseOutlineViewModel.courseSubSectionUnit[subSectionBlock.id]?.let { unit -> + courseOutlineViewModel.logUnitDetailViewedEvent( + unit.blockId, + unit.displayName + ) + courseRouter.navigateToCourseContainer( + fragmentManager, + courseId = courseOutlineViewModel.courseId, + unitId = unit.id, + mode = CourseViewMode.FULL + ) + } + } else { + courseOutlineViewModel.sequentialClickedEvent( + subSectionBlock.blockId, + subSectionBlock.displayName ) - courseRouter.navigateToCourseContainer( - fragmentManager, + courseRouter.navigateToCourseSubsections( + fm = fragmentManager, courseId = courseOutlineViewModel.courseId, - unitId = unit.id, + subSectionId = subSectionBlock.id, mode = CourseViewMode.FULL ) } @@ -177,7 +178,6 @@ private fun CourseOutlineUI( windowSize: WindowSize, uiState: CourseOutlineUIState, uiMessage: UIMessage?, - onItemClick: (Block) -> Unit, onExpandClick: (Block) -> Unit, onSubSectionClick: (Block) -> Unit, onResumeClick: (String) -> Unit, @@ -499,7 +499,6 @@ private fun CourseOutlineScreenPreview() { ) ), uiMessage = null, - onItemClick = {}, onExpandClick = {}, onSubSectionClick = {}, onResumeClick = {}, @@ -533,7 +532,6 @@ private fun CourseOutlineScreenTabletPreview() { ) ), uiMessage = null, - onItemClick = {}, onExpandClick = {}, onSubSectionClick = {}, onResumeClick = {}, diff --git a/course/src/main/java/org/openedx/course/presentation/outline/CourseOutlineViewModel.kt b/course/src/main/java/org/openedx/course/presentation/outline/CourseOutlineViewModel.kt index 9d7e2ff23..e9d2ca403 100644 --- a/course/src/main/java/org/openedx/course/presentation/outline/CourseOutlineViewModel.kt +++ b/course/src/main/java/org/openedx/course/presentation/outline/CourseOutlineViewModel.kt @@ -59,6 +59,7 @@ class CourseOutlineViewModel( workerController, coreAnalytics ) { + val isCourseNestedListEnabled get() = config.isCourseNestedListEnabled() private val _uiState = MutableStateFlow(CourseOutlineUIState.Loading) val uiState: StateFlow From 9f75619e888bef49957a8125c956a883c4ed8bc4 Mon Sep 17 00:00:00 2001 From: PavloNetrebchuk Date: Tue, 28 May 2024 18:45:49 +0300 Subject: [PATCH 05/18] fix: course progress bar updating --- .../course/presentation/container/CourseContainerFragment.kt | 5 +++++ 1 file changed, 5 insertions(+) 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 669b1f661..39a8098f3 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 @@ -112,6 +112,11 @@ class CourseContainerFragment : Fragment(R.layout.fragment_course_container) { observe() } + override fun onResume() { + super.onResume() + viewModel.updateData() + } + override fun onDestroyView() { snackBar?.dismiss() super.onDestroyView() From 4989c52de43a7238f1dd6aa30bcf0ae04a505e20 Mon Sep 17 00:00:00 2001 From: PavloNetrebchuk Date: Wed, 29 May 2024 19:51:15 +0300 Subject: [PATCH 06/18] feat: Renamed COURSE_NESTED_LIST_ENABLE feature flag --- Documentation/ConfigurationManagement.md | 2 +- .../java/org/openedx/core/config/Config.kt | 6 ++-- .../dates/CourseDatesViewModel.kt | 2 +- .../outline/CourseOutlineViewModel.kt | 2 +- .../course/presentation/ui/CourseUI.kt | 8 ++++- .../container/CourseUnitContainerViewModel.kt | 2 +- course/src/main/res/values/strings.xml | 1 + .../container/CourseContainerViewModelTest.kt | 20 ++++++----- .../dates/CourseDatesViewModelTest.kt | 1 + .../outline/CourseOutlineViewModelTest.kt | 34 ++++++++++++------ .../section/CourseSectionViewModelTest.kt | 21 ++++++++--- .../CourseUnitContainerViewModelTest.kt | 26 +++++++++++--- .../videos/CourseVideoViewModelTest.kt | 36 +++++++++++++------ default_config/dev/config.yaml | 3 +- default_config/prod/config.yaml | 2 +- default_config/stage/config.yaml | 2 +- 16 files changed, 117 insertions(+), 51 deletions(-) diff --git a/Documentation/ConfigurationManagement.md b/Documentation/ConfigurationManagement.md index b1e21a50b..1f12f3414 100644 --- a/Documentation/ConfigurationManagement.md +++ b/Documentation/ConfigurationManagement.md @@ -88,7 +88,7 @@ android: - **PRE_LOGIN_EXPERIENCE_ENABLED:** Enables the pre login courses discovery experience. - **WHATS_NEW_ENABLED:** Enables the "What's New" feature to present the latest changes to the user. - **SOCIAL_AUTH_ENABLED:** Enables SSO buttons on the SignIn and SignUp screens. -- **COURSE_NESTED_LIST_ENABLED:** Enables an alternative visual representation for the course structure. +- **COURSE_DROPDOWN_NAVIGATION_ENABLED:** Enables an alternative visual representation for the course structure. - **COURSE_UNIT_PROGRESS_ENABLED:** Enables the display of the unit progress within the courseware. ## Future Support diff --git a/core/src/main/java/org/openedx/core/config/Config.kt b/core/src/main/java/org/openedx/core/config/Config.kt index 4b40fbc29..b20ea9f0a 100644 --- a/core/src/main/java/org/openedx/core/config/Config.kt +++ b/core/src/main/java/org/openedx/core/config/Config.kt @@ -103,8 +103,8 @@ class Config(context: Context) { return getBoolean(PRE_LOGIN_EXPERIENCE_ENABLED, true) } - fun isCourseNestedListEnabled(): Boolean { - return getBoolean(COURSE_NESTED_LIST_ENABLED, false) + fun isCourseDropdownNavigationEnabled(): Boolean { + return getBoolean(COURSE_DROPDOWN_NAVIGATION_ENABLED, false) } fun isCourseUnitProgressEnabled(): Boolean { @@ -165,7 +165,7 @@ class Config(context: Context) { private const val DISCOVERY = "DISCOVERY" private const val PROGRAM = "PROGRAM" private const val BRANCH = "BRANCH" - private const val COURSE_NESTED_LIST_ENABLED = "COURSE_NESTED_LIST_ENABLED" + private const val COURSE_DROPDOWN_NAVIGATION_ENABLED = "COURSE_DROPDOWN_NAVIGATION_ENABLED" private const val COURSE_UNIT_PROGRESS_ENABLED = "COURSE_UNIT_PROGRESS_ENABLED" private const val PLATFORM_NAME = "PLATFORM_NAME" } diff --git a/course/src/main/java/org/openedx/course/presentation/dates/CourseDatesViewModel.kt b/course/src/main/java/org/openedx/course/presentation/dates/CourseDatesViewModel.kt index e5ce08ed7..064b442a6 100644 --- a/course/src/main/java/org/openedx/course/presentation/dates/CourseDatesViewModel.kt +++ b/course/src/main/java/org/openedx/course/presentation/dates/CourseDatesViewModel.kt @@ -76,7 +76,7 @@ class CourseDatesViewModel( private var courseBannerType: CourseBannerType = CourseBannerType.BLANK private var courseStructure: CourseStructure? = null - val isCourseExpandableSectionsEnabled get() = config.isCourseNestedListEnabled() + val isCourseExpandableSectionsEnabled get() = config.isCourseDropdownNavigationEnabled() init { viewModelScope.launch { diff --git a/course/src/main/java/org/openedx/course/presentation/outline/CourseOutlineViewModel.kt b/course/src/main/java/org/openedx/course/presentation/outline/CourseOutlineViewModel.kt index e9d2ca403..65ffb97fa 100644 --- a/course/src/main/java/org/openedx/course/presentation/outline/CourseOutlineViewModel.kt +++ b/course/src/main/java/org/openedx/course/presentation/outline/CourseOutlineViewModel.kt @@ -59,7 +59,7 @@ class CourseOutlineViewModel( workerController, coreAnalytics ) { - val isCourseNestedListEnabled get() = config.isCourseNestedListEnabled() + val isCourseNestedListEnabled get() = config.isCourseDropdownNavigationEnabled() private val _uiState = MutableStateFlow(CourseOutlineUIState.Loading) val uiState: StateFlow 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 4b192251c..6dd6461ab 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 @@ -814,7 +814,13 @@ fun CourseSubSectionItem( if (isAssignmentEnable) { val assignmentString = - "${block.assignmentProgress?.assignmentType} - $due - ${block.assignmentProgress?.numPointsEarned?.toInt()} / ${block.assignmentProgress?.numPointsPossible?.toInt()}" + stringResource( + R.string.course_subsection_assignment_info, + block.assignmentProgress?.assignmentType ?: "", + due ?: "", + block.assignmentProgress?.numPointsEarned?.toInt() ?: 0, + block.assignmentProgress?.numPointsPossible?.toInt() ?: 0 + ) Spacer(modifier = Modifier.height(8.dp)) Text( text = assignmentString, 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 f479f08c0..f04dd3bed 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 @@ -37,7 +37,7 @@ class CourseUnitContainerViewModel( private val blocks = ArrayList() - val isCourseExpandableSectionsEnabled get() = config.isCourseNestedListEnabled() + val isCourseExpandableSectionsEnabled get() = config.isCourseDropdownNavigationEnabled() val isCourseUnitProgressEnabled get() = config.isCourseUnitProgressEnabled() diff --git a/course/src/main/res/values/strings.xml b/course/src/main/res/values/strings.xml index 3d48a9e78..6e5f67254 100644 --- a/course/src/main/res/values/strings.xml +++ b/course/src/main/res/values/strings.xml @@ -96,5 +96,6 @@ Are you sure you want to delete all video(s) for \"%s\"? Are you sure you want to delete video(s) for \"%s\"? %1$s of %2$s assignments complete + %1$s - Due %2$s - %3$d / %4$d diff --git a/course/src/test/java/org/openedx/course/presentation/container/CourseContainerViewModelTest.kt b/course/src/test/java/org/openedx/course/presentation/container/CourseContainerViewModelTest.kt index 1b2cb6cca..5500c550e 100644 --- a/course/src/test/java/org/openedx/course/presentation/container/CourseContainerViewModelTest.kt +++ b/course/src/test/java/org/openedx/course/presentation/container/CourseContainerViewModelTest.kt @@ -108,7 +108,8 @@ class CourseContainerViewModelTest { ), media = null, certificate = null, - isSelfPaced = false + isSelfPaced = false, + progress = null ) private val courseStructureModel = CourseStructureModel( @@ -125,7 +126,8 @@ class CourseContainerViewModelTest { coursewareAccess = null, media = null, certificate = null, - isSelfPaced = false + isSelfPaced = false, + progress = null ) @Before @@ -167,12 +169,12 @@ class CourseContainerViewModelTest { courseRouter ) every { networkConnection.isOnline() } returns true - coEvery { interactor.getCourseStructure(any()) } throws UnknownHostException() + coEvery { interactor.getCourseStructure(any(), any()) } throws UnknownHostException() every { analytics.logEvent(CourseAnalyticsEvent.DASHBOARD.eventName, any()) } returns Unit viewModel.preloadCourseStructure() advanceUntilIdle() - coVerify(exactly = 1) { interactor.getCourseStructure(any()) } + coVerify(exactly = 1) { interactor.getCourseStructure(any(), any()) } verify(exactly = 1) { analytics.logEvent(CourseAnalyticsEvent.DASHBOARD.eventName, any()) } val message = viewModel.errorMessage.value @@ -200,12 +202,12 @@ class CourseContainerViewModelTest { courseRouter ) every { networkConnection.isOnline() } returns true - coEvery { interactor.getCourseStructure(any()) } throws Exception() + coEvery { interactor.getCourseStructure(any(), any()) } throws Exception() every { analytics.logEvent(CourseAnalyticsEvent.DASHBOARD.eventName, any()) } returns Unit viewModel.preloadCourseStructure() advanceUntilIdle() - coVerify(exactly = 1) { interactor.getCourseStructure(any()) } + coVerify(exactly = 1) { interactor.getCourseStructure(any(), any()) } verify(exactly = 1) { analytics.logEvent(CourseAnalyticsEvent.DASHBOARD.eventName, any()) } val message = viewModel.errorMessage.value @@ -233,12 +235,12 @@ class CourseContainerViewModelTest { courseRouter ) every { networkConnection.isOnline() } returns true - coEvery { interactor.getCourseStructure(any()) } returns courseStructure + coEvery { interactor.getCourseStructure(any(), any()) } returns courseStructure every { analytics.logEvent(CourseAnalyticsEvent.DASHBOARD.eventName, any()) } returns Unit viewModel.preloadCourseStructure() advanceUntilIdle() - coVerify(exactly = 1) { interactor.getCourseStructure(any()) } + coVerify(exactly = 1) { interactor.getCourseStructure(any(), any()) } verify(exactly = 1) { analytics.logEvent(CourseAnalyticsEvent.DASHBOARD.eventName, any()) } assert(viewModel.errorMessage.value == null) @@ -265,7 +267,7 @@ class CourseContainerViewModelTest { courseRouter ) every { networkConnection.isOnline() } returns false - coEvery { interactor.getCourseStructure(any()) } returns courseStructure + coEvery { interactor.getCourseStructure(any(), any()) } returns courseStructure every { analytics.logEvent(any(), any()) } returns Unit coEvery { courseApi.getCourseStructure(any(), any(), any(), any()) diff --git a/course/src/test/java/org/openedx/course/presentation/dates/CourseDatesViewModelTest.kt b/course/src/test/java/org/openedx/course/presentation/dates/CourseDatesViewModelTest.kt index 13e78fe91..760226984 100644 --- a/course/src/test/java/org/openedx/course/presentation/dates/CourseDatesViewModelTest.kt +++ b/course/src/test/java/org/openedx/course/presentation/dates/CourseDatesViewModelTest.kt @@ -134,6 +134,7 @@ class CourseDatesViewModelTest { media = null, certificate = null, isSelfPaced = true, + progress = null ) @Before diff --git a/course/src/test/java/org/openedx/course/presentation/outline/CourseOutlineViewModelTest.kt b/course/src/test/java/org/openedx/course/presentation/outline/CourseOutlineViewModelTest.kt index c2b2cff57..d99138499 100644 --- a/course/src/test/java/org/openedx/course/presentation/outline/CourseOutlineViewModelTest.kt +++ b/course/src/test/java/org/openedx/course/presentation/outline/CourseOutlineViewModelTest.kt @@ -33,6 +33,7 @@ import org.openedx.core.UIMessage import org.openedx.core.config.Config import org.openedx.core.data.model.DateType import org.openedx.core.data.storage.CorePreferences +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 @@ -82,6 +83,12 @@ class CourseOutlineViewModelTest { private val somethingWrong = "Something went wrong" private val cantDownload = "You can download content only from Wi-fi" + private val assignmentProgress = AssignmentProgress( + assignmentType = "Homework", + numPointsEarned = 1f, + numPointsPossible = 3f + ) + private val blocks = listOf( Block( id = "id", @@ -97,7 +104,9 @@ class CourseOutlineViewModelTest { blockCounts = BlockCounts(0), descendants = listOf("1", "id1"), descendantsType = BlockType.HTML, - completion = 0.0 + completion = 0.0, + assignmentProgress = assignmentProgress, + due = Date() ), Block( id = "id1", @@ -113,7 +122,9 @@ class CourseOutlineViewModelTest { blockCounts = BlockCounts(0), descendants = listOf("id2"), descendantsType = BlockType.HTML, - completion = 0.0 + completion = 0.0, + assignmentProgress = assignmentProgress, + due = Date() ), Block( id = "id2", @@ -129,7 +140,9 @@ class CourseOutlineViewModelTest { blockCounts = BlockCounts(0), descendants = emptyList(), descendantsType = BlockType.HTML, - completion = 0.0 + completion = 0.0, + assignmentProgress = assignmentProgress, + due = Date() ) ) @@ -154,7 +167,8 @@ class CourseOutlineViewModelTest { ), media = null, certificate = null, - isSelfPaced = false + isSelfPaced = false, + progress = null ) private val dateBlock = CourseDateBlock( @@ -299,7 +313,7 @@ class CourseOutlineViewModelTest { ) } coEvery { interactor.getCourseStatus(any()) } returns CourseComponentStatus("id") - every { config.isCourseNestedListEnabled() } returns false + every { config.isCourseDropdownNavigationEnabled() } returns false val viewModel = CourseOutlineViewModel( "", @@ -346,7 +360,7 @@ class CourseOutlineViewModelTest { ) } coEvery { interactor.getCourseStatus(any()) } returns CourseComponentStatus("id") - every { config.isCourseNestedListEnabled() } returns false + every { config.isCourseDropdownNavigationEnabled() } returns false val viewModel = CourseOutlineViewModel( "", @@ -392,7 +406,7 @@ class CourseOutlineViewModelTest { ) } coEvery { interactor.getCourseStatus(any()) } returns CourseComponentStatus("id") - every { config.isCourseNestedListEnabled() } returns false + every { config.isCourseDropdownNavigationEnabled() } returns false val viewModel = CourseOutlineViewModel( "", @@ -474,7 +488,7 @@ class CourseOutlineViewModelTest { coEvery { workerController.saveModels(any()) } returns Unit coEvery { interactor.getCourseStatus(any()) } returns CourseComponentStatus("id") coEvery { downloadDao.readAllData() } returns flow { emit(emptyList()) } - every { config.isCourseNestedListEnabled() } returns false + every { config.isCourseDropdownNavigationEnabled() } returns false val viewModel = CourseOutlineViewModel( "", @@ -516,7 +530,7 @@ class CourseOutlineViewModelTest { every { networkConnection.isOnline() } returns true coEvery { workerController.saveModels(any()) } returns Unit coEvery { downloadDao.readAllData() } returns flow { emit(emptyList()) } - every { config.isCourseNestedListEnabled() } returns false + every { config.isCourseDropdownNavigationEnabled() } returns false every { coreAnalytics.logEvent(any(), any()) } returns Unit val viewModel = CourseOutlineViewModel( @@ -552,7 +566,7 @@ class CourseOutlineViewModelTest { every { networkConnection.isOnline() } returns false coEvery { workerController.saveModels(any()) } returns Unit coEvery { downloadDao.readAllData() } returns flow { emit(emptyList()) } - every { config.isCourseNestedListEnabled() } returns false + every { config.isCourseDropdownNavigationEnabled() } returns false val viewModel = CourseOutlineViewModel( "", 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 0a398371b..01c685c48 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 @@ -27,6 +27,7 @@ import org.openedx.core.BlockType import org.openedx.core.R import org.openedx.core.UIMessage import org.openedx.core.data.storage.CorePreferences +import org.openedx.core.domain.model.AssignmentProgress import org.openedx.core.domain.model.Block import org.openedx.core.domain.model.BlockCounts import org.openedx.core.domain.model.CourseStructure @@ -69,6 +70,11 @@ class CourseSectionViewModelTest { private val somethingWrong = "Something went wrong" private val cantDownload = "You can download content only from Wi-fi" + private val assignmentProgress = AssignmentProgress( + assignmentType = "Homework", + numPointsEarned = 1f, + numPointsPossible = 3f + ) private val blocks = listOf( Block( @@ -85,7 +91,9 @@ class CourseSectionViewModelTest { blockCounts = BlockCounts(0), descendants = listOf("1", "id1"), descendantsType = BlockType.HTML, - completion = 0.0 + completion = 0.0, + assignmentProgress = assignmentProgress, + due = Date() ), Block( id = "id1", @@ -101,7 +109,9 @@ class CourseSectionViewModelTest { blockCounts = BlockCounts(0), descendants = listOf("id2"), descendantsType = BlockType.HTML, - completion = 0.0 + completion = 0.0, + assignmentProgress = assignmentProgress, + due = Date() ), Block( id = "id2", @@ -117,7 +127,9 @@ class CourseSectionViewModelTest { blockCounts = BlockCounts(0), descendants = emptyList(), descendantsType = BlockType.HTML, - completion = 0.0 + completion = 0.0, + assignmentProgress = assignmentProgress, + due = Date() ) ) @@ -142,7 +154,8 @@ class CourseSectionViewModelTest { ), media = null, certificate = null, - isSelfPaced = false + isSelfPaced = false, + progress = null ) private val downloadModel = DownloadModel( 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 1e5354a95..166d7751e 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 @@ -20,6 +20,7 @@ import org.junit.Test import org.junit.rules.TestRule import org.openedx.core.BlockType import org.openedx.core.config.Config +import org.openedx.core.domain.model.AssignmentProgress import org.openedx.core.domain.model.Block import org.openedx.core.domain.model.BlockCounts import org.openedx.core.domain.model.CourseStructure @@ -44,6 +45,12 @@ class CourseUnitContainerViewModelTest { private val notifier = mockk() private val analytics = mockk() + private val assignmentProgress = AssignmentProgress( + assignmentType = "Homework", + numPointsEarned = 1f, + numPointsPossible = 3f + ) + private val blocks = listOf( Block( id = "id", @@ -59,7 +66,9 @@ class CourseUnitContainerViewModelTest { blockCounts = BlockCounts(0), descendants = listOf("id2", "id1"), descendantsType = BlockType.HTML, - completion = 0.0 + completion = 0.0, + assignmentProgress = assignmentProgress, + due = Date() ), Block( id = "id1", @@ -75,7 +84,9 @@ class CourseUnitContainerViewModelTest { blockCounts = BlockCounts(0), descendants = listOf("id2", "id"), descendantsType = BlockType.HTML, - completion = 0.0 + completion = 0.0, + assignmentProgress = assignmentProgress, + due = Date() ), Block( id = "id2", @@ -91,7 +102,9 @@ class CourseUnitContainerViewModelTest { blockCounts = BlockCounts(0), descendants = emptyList(), descendantsType = BlockType.HTML, - completion = 0.0 + completion = 0.0, + assignmentProgress = assignmentProgress, + due = Date() ), Block( id = "id3", @@ -107,7 +120,9 @@ class CourseUnitContainerViewModelTest { blockCounts = BlockCounts(0), descendants = emptyList(), descendantsType = BlockType.HTML, - completion = 0.0 + completion = 0.0, + assignmentProgress = assignmentProgress, + due = Date() ) ) @@ -133,7 +148,8 @@ class CourseUnitContainerViewModelTest { ), media = null, certificate = null, - isSelfPaced = false + isSelfPaced = false, + progress = null ) @Before diff --git a/course/src/test/java/org/openedx/course/presentation/videos/CourseVideoViewModelTest.kt b/course/src/test/java/org/openedx/course/presentation/videos/CourseVideoViewModelTest.kt index a2dae8b2e..aa403c893 100644 --- a/course/src/test/java/org/openedx/course/presentation/videos/CourseVideoViewModelTest.kt +++ b/course/src/test/java/org/openedx/course/presentation/videos/CourseVideoViewModelTest.kt @@ -32,6 +32,7 @@ import org.openedx.core.BlockType import org.openedx.core.UIMessage import org.openedx.core.config.Config import org.openedx.core.data.storage.CorePreferences +import org.openedx.core.domain.model.AssignmentProgress import org.openedx.core.domain.model.Block import org.openedx.core.domain.model.BlockCounts import org.openedx.core.domain.model.CourseStructure @@ -76,6 +77,12 @@ class CourseVideoViewModelTest { private val cantDownload = "You can download content only from Wi-fi" + private val assignmentProgress = AssignmentProgress( + assignmentType = "Homework", + numPointsEarned = 1f, + numPointsPossible = 3f + ) + private val blocks = listOf( Block( id = "id", @@ -91,7 +98,9 @@ class CourseVideoViewModelTest { blockCounts = BlockCounts(0), descendants = listOf("1", "id1"), descendantsType = BlockType.HTML, - completion = 0.0 + completion = 0.0, + assignmentProgress = assignmentProgress, + due = Date() ), Block( id = "id1", @@ -107,7 +116,9 @@ class CourseVideoViewModelTest { blockCounts = BlockCounts(0), descendants = listOf("id2"), descendantsType = BlockType.HTML, - completion = 0.0 + completion = 0.0, + assignmentProgress = assignmentProgress, + due = Date() ), Block( id = "id2", @@ -123,7 +134,9 @@ class CourseVideoViewModelTest { blockCounts = BlockCounts(0), descendants = emptyList(), descendantsType = BlockType.HTML, - completion = 0.0 + completion = 0.0, + assignmentProgress = assignmentProgress, + due = Date() ) ) @@ -148,7 +161,8 @@ class CourseVideoViewModelTest { ), media = null, certificate = null, - isSelfPaced = false + isSelfPaced = false, + progress = null ) private val downloadModelEntity = @@ -181,7 +195,7 @@ class CourseVideoViewModelTest { @Test fun `getVideos empty list`() = runTest { - every { config.isCourseNestedListEnabled() } returns false + every { config.isCourseDropdownNavigationEnabled() } returns false coEvery { interactor.getCourseStructureForVideos(any()) } returns courseStructure.copy(blockData = emptyList()) every { downloadDao.readAllData() } returns flow { emit(emptyList()) } @@ -212,7 +226,7 @@ class CourseVideoViewModelTest { @Test fun `getVideos success`() = runTest { - every { config.isCourseNestedListEnabled() } returns false + every { config.isCourseDropdownNavigationEnabled() } returns false coEvery { interactor.getCourseStructureForVideos(any()) } returns courseStructure every { downloadDao.readAllData() } returns flow { emit(emptyList()) } every { preferencesManager.videoSettings } returns VideoSettings.default @@ -244,7 +258,7 @@ class CourseVideoViewModelTest { @Test fun `updateVideos success`() = runTest { - every { config.isCourseNestedListEnabled() } returns false + every { config.isCourseDropdownNavigationEnabled() } returns false coEvery { interactor.getCourseStructureForVideos(any()) } returns courseStructure coEvery { courseNotifier.notifier } returns flow { emit(CourseStructureUpdated("")) @@ -286,7 +300,7 @@ class CourseVideoViewModelTest { @Test fun `setIsUpdating success`() = runTest { - every { config.isCourseNestedListEnabled() } returns false + every { config.isCourseDropdownNavigationEnabled() } returns false every { preferencesManager.videoSettings } returns VideoSettings.default coEvery { interactor.getCourseStructureForVideos(any()) } returns courseStructure coEvery { downloadDao.readAllData() } returns flow { emit(listOf(downloadModelEntity)) } @@ -295,7 +309,7 @@ class CourseVideoViewModelTest { @Test fun `saveDownloadModels test`() = runTest(UnconfinedTestDispatcher()) { - every { config.isCourseNestedListEnabled() } returns false + every { config.isCourseDropdownNavigationEnabled() } returns false every { preferencesManager.videoSettings } returns VideoSettings.default val viewModel = CourseVideoViewModel( "", @@ -331,7 +345,7 @@ class CourseVideoViewModelTest { @Test fun `saveDownloadModels only wifi download, with connection`() = runTest(UnconfinedTestDispatcher()) { - every { config.isCourseNestedListEnabled() } returns false + every { config.isCourseDropdownNavigationEnabled() } returns false every { preferencesManager.videoSettings } returns VideoSettings.default val viewModel = CourseVideoViewModel( "", @@ -371,7 +385,7 @@ class CourseVideoViewModelTest { @Test fun `saveDownloadModels only wifi download, without connection`() = runTest(UnconfinedTestDispatcher()) { - every { config.isCourseNestedListEnabled() } returns false + every { config.isCourseDropdownNavigationEnabled() } returns false every { preferencesManager.videoSettings } returns VideoSettings.default val viewModel = CourseVideoViewModel( "", diff --git a/default_config/dev/config.yaml b/default_config/dev/config.yaml index e1582bfcf..95885ba8a 100644 --- a/default_config/dev/config.yaml +++ b/default_config/dev/config.yaml @@ -76,6 +76,5 @@ WHATS_NEW_ENABLED: false #feature flag enable Social Login buttons SOCIAL_AUTH_ENABLED: false #Course navigation feature flags -COURSE_NESTED_LIST_ENABLED: false +COURSE_DROPDOWN_NAVIGATION_ENABLED: false COURSE_UNIT_PROGRESS_ENABLED: false - diff --git a/default_config/prod/config.yaml b/default_config/prod/config.yaml index f7afc7bed..95885ba8a 100644 --- a/default_config/prod/config.yaml +++ b/default_config/prod/config.yaml @@ -76,5 +76,5 @@ WHATS_NEW_ENABLED: false #feature flag enable Social Login buttons SOCIAL_AUTH_ENABLED: false #Course navigation feature flags -COURSE_NESTED_LIST_ENABLED: false +COURSE_DROPDOWN_NAVIGATION_ENABLED: false COURSE_UNIT_PROGRESS_ENABLED: false diff --git a/default_config/stage/config.yaml b/default_config/stage/config.yaml index f7afc7bed..95885ba8a 100644 --- a/default_config/stage/config.yaml +++ b/default_config/stage/config.yaml @@ -76,5 +76,5 @@ WHATS_NEW_ENABLED: false #feature flag enable Social Login buttons SOCIAL_AUTH_ENABLED: false #Course navigation feature flags -COURSE_NESTED_LIST_ENABLED: false +COURSE_DROPDOWN_NAVIGATION_ENABLED: false COURSE_UNIT_PROGRESS_ENABLED: false From 2ca4f5fd8138d211546140579a3cfcc92020f686 Mon Sep 17 00:00:00 2001 From: Volodymyr Chekyrta Date: Tue, 14 May 2024 22:47:47 +0300 Subject: [PATCH 07/18] feat: Course home. Moved certificate access. --- .../outline/CourseOutlineScreen.kt | 26 ++ .../course/presentation/ui/CourseUI.kt | 310 ++++++++++-------- .../res/drawable/ic_course_certificate.xml | 9 + .../res/drawable/ic_course_completed_mark.xml | 31 -- course/src/main/res/values-uk/strings.xml | 6 +- course/src/main/res/values/strings.xml | 8 +- 6 files changed, 203 insertions(+), 187 deletions(-) create mode 100644 course/src/main/res/drawable/ic_course_certificate.xml delete mode 100644 course/src/main/res/drawable/ic_course_completed_mark.xml diff --git a/course/src/main/java/org/openedx/course/presentation/outline/CourseOutlineScreen.kt b/course/src/main/java/org/openedx/course/presentation/outline/CourseOutlineScreen.kt index e1652c3eb..d2e47a9cc 100644 --- a/course/src/main/java/org/openedx/course/presentation/outline/CourseOutlineScreen.kt +++ b/course/src/main/java/org/openedx/course/presentation/outline/CourseOutlineScreen.kt @@ -68,6 +68,10 @@ import org.openedx.course.presentation.CourseRouter import org.openedx.course.presentation.ui.CourseDatesBanner import org.openedx.course.presentation.ui.CourseDatesBannerTablet import org.openedx.course.presentation.ui.CourseSection +import org.openedx.course.presentation.ui.CourseExpandableChapterCard +import org.openedx.course.presentation.ui.CourseMessage +import org.openedx.course.presentation.ui.CourseSectionCard +import org.openedx.course.presentation.ui.CourseSubSectionItem import java.io.File import java.util.Date import org.openedx.core.R as CoreR @@ -262,6 +266,28 @@ private fun CourseOutlineUI( } } + val certificate = uiState.courseStructure.certificate + if (certificate?.isCertificateEarned() == true) { + item { + CourseMessage( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 12.dp) + .then(listPadding), + icon = painterResource(R.drawable.ic_course_certificate), + message = stringResource( + R.string.course_you_earned_certificate, + uiState.courseStructure.name + ), + action = stringResource(R.string.course_view_certificate), + onActionClick = { + onCertificateClick(certificate.certificateURL ?: "") + } + ) + } + } + + val progress = uiState.courseStructure.progress if (progress != null && progress.totalAssignmentsCount > 0) { item { 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 6dd6461ab..02e71df33 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,7 +17,6 @@ import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxHeight -import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.navigationBarsPadding @@ -64,27 +63,24 @@ import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.rotate import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.vector.rememberVectorPainter -import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.graphics.painter.Painter import androidx.compose.ui.platform.LocalConfiguration -import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.semantics import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextDecoration import androidx.compose.ui.text.style.TextOverflow 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.compose.ui.zIndex -import coil.compose.AsyncImage -import coil.request.ImageRequest import org.jsoup.Jsoup 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.domain.model.Certificate import org.openedx.core.domain.model.CourseDatesBannerInfo import org.openedx.core.extension.isLinkValid import org.openedx.core.extension.nonZero @@ -98,7 +94,6 @@ import org.openedx.core.ui.OpenEdXButton import org.openedx.core.ui.OpenEdXOutlinedButton import org.openedx.core.ui.displayCutoutForLandscape import org.openedx.core.ui.noRippleClickable -import org.openedx.core.ui.rememberWindowSize import org.openedx.core.ui.theme.OpenEdXTheme import org.openedx.core.ui.theme.appColors import org.openedx.core.ui.theme.appShapes @@ -113,83 +108,101 @@ import java.util.Date import org.openedx.core.R as coreR @Composable -fun CourseImageHeader( - modifier: Modifier, - apiHostUrl: String, - courseImage: String?, - courseCertificate: Certificate?, - onCertificateClick: (String) -> Unit = {}, - courseName: String, +fun CourseSectionCard( + block: Block, + downloadedState: DownloadedState?, + onItemClick: (Block) -> Unit, + onDownloadClick: (Block) -> Unit ) { - val configuration = LocalConfiguration.current - val windowSize = rememberWindowSize() - val contentScale = - if (!windowSize.isTablet && configuration.orientation == Configuration.ORIENTATION_LANDSCAPE) { - ContentScale.Fit - } else { - ContentScale.Crop - } - val imageUrl = if (courseImage?.isLinkValid() == true) { - courseImage - } else { - apiHostUrl.dropLast(1) + courseImage - } - Box(modifier = modifier, contentAlignment = Alignment.Center) { - AsyncImage( - model = ImageRequest.Builder(LocalContext.current) - .data(imageUrl) - .error(coreR.drawable.core_no_image_course) - .placeholder(coreR.drawable.core_no_image_course) - .build(), - contentDescription = stringResource( - id = coreR.string.core_accessibility_header_image_for, - courseName - ), - contentScale = contentScale, - modifier = Modifier - .fillMaxSize() - .clip(MaterialTheme.appShapes.cardShape) - ) - if (courseCertificate?.isCertificateEarned() == true) { - Column( - Modifier - .fillMaxSize() - .clip(MaterialTheme.appShapes.cardShape) - .background(MaterialTheme.appColors.certificateForeground), - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.Center - ) { - Icon( - modifier = Modifier.testTag("ic_congratulations"), - painter = painterResource(id = R.drawable.ic_course_completed_mark), - contentDescription = stringResource(id = R.string.course_congratulations), - tint = Color.White - ) - Spacer(Modifier.height(6.dp)) - Text( - modifier = Modifier.testTag("txt_congratulations"), - text = stringResource(id = R.string.course_congratulations), - style = MaterialTheme.appTypography.headlineMedium, - color = Color.White - ) - Spacer(Modifier.height(4.dp)) - Text( - modifier = Modifier.testTag("txt_course_passed"), - text = stringResource(id = R.string.course_passed), - style = MaterialTheme.appTypography.bodyMedium, - color = Color.White + val iconModifier = Modifier.size(24.dp) + + Column(Modifier.clickable { onItemClick(block) }) { + Row( + Modifier + .fillMaxWidth() + .height(80.dp) + .padding( + horizontal = 20.dp, + vertical = 24.dp + ), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween + ) { + val completedIconPainter = + if (block.isCompleted()) painterResource(R.drawable.course_ic_task_alt) else painterResource( + R.drawable.ic_course_chapter_icon ) - Spacer(Modifier.height(20.dp)) - OpenEdXOutlinedButton( - modifier = Modifier, - borderColor = Color.White, - textColor = MaterialTheme.appColors.buttonText, - text = stringResource(id = R.string.course_view_certificate), - onClick = { - courseCertificate.certificateURL?.let { - onCertificateClick(it) + val completedIconColor = + if (block.isCompleted()) MaterialTheme.appColors.primary else MaterialTheme.appColors.onSurface + val completedIconDescription = if (block.isCompleted()) { + stringResource(id = R.string.course_accessibility_section_completed) + } else { + stringResource(id = R.string.course_accessibility_section_uncompleted) + } + Icon( + painter = completedIconPainter, + contentDescription = completedIconDescription, + tint = completedIconColor + ) + Spacer(modifier = Modifier.width(16.dp)) + Text( + modifier = Modifier.weight(1f), + text = block.displayName, + style = MaterialTheme.appTypography.titleSmall, + color = MaterialTheme.appColors.textPrimary, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + Spacer(modifier = Modifier.width(16.dp)) + Row( + modifier = Modifier.fillMaxHeight(), + horizontalArrangement = Arrangement.spacedBy(24.dp), + verticalAlignment = Alignment.CenterVertically + ) { + if (downloadedState == DownloadedState.DOWNLOADED || downloadedState == DownloadedState.NOT_DOWNLOADED) { + val downloadIconPainter = if (downloadedState == DownloadedState.DOWNLOADED) { + painterResource(id = R.drawable.course_ic_remove_download) + } else { + painterResource(id = R.drawable.course_ic_start_download) + } + val downloadIconDescription = + if (downloadedState == DownloadedState.DOWNLOADED) { + stringResource(id = R.string.course_accessibility_remove_course_section) + } else { + stringResource(id = R.string.course_accessibility_download_course_section) } - }) + IconButton(modifier = iconModifier, + onClick = { onDownloadClick(block) }) { + Icon( + painter = downloadIconPainter, + contentDescription = downloadIconDescription, + tint = MaterialTheme.appColors.textPrimary + ) + } + } else if (downloadedState != null) { + Box(contentAlignment = Alignment.Center) { + if (downloadedState == DownloadedState.DOWNLOADING || downloadedState == DownloadedState.WAITING) { + CircularProgressIndicator( + modifier = Modifier.size(34.dp), + backgroundColor = Color.LightGray, + strokeWidth = 2.dp, + color = MaterialTheme.appColors.primary + ) + } + IconButton( + modifier = iconModifier.padding(top = 2.dp), + onClick = { onDownloadClick(block) }) { + Icon( + imageVector = Icons.Filled.Close, + contentDescription = stringResource(id = R.string.course_accessibility_stop_downloading_course_section), + tint = MaterialTheme.appColors.error + ) + } + } + } + CardArrow( + degrees = 0f + ) } } } @@ -278,48 +291,6 @@ fun CardArrow( ) } -@Composable -fun SequentialItem( - block: Block, - onClick: (Block) -> Unit -) { - val icon = if (block.isCompleted()) Icons.Filled.TaskAlt else Icons.Filled.Home - val iconColor = - if (block.isCompleted()) MaterialTheme.appColors.primary else MaterialTheme.appColors.onSurface - Row( - Modifier - .fillMaxWidth() - .padding( - horizontal = 20.dp, - vertical = 12.dp - ) - .clickable { onClick(block) }, - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.SpaceBetween - ) { - Row(Modifier.weight(1f)) { - Icon( - imageVector = icon, - contentDescription = null, - tint = iconColor - ) - Spacer(modifier = Modifier.width(16.dp)) - Text( - block.displayName, - style = MaterialTheme.appTypography.titleMedium, - color = MaterialTheme.appColors.textPrimary, - overflow = TextOverflow.Ellipsis, - maxLines = 1 - ) - } - Icon( - imageVector = Icons.Filled.ChevronRight, - tint = MaterialTheme.appColors.onSurface, - contentDescription = "Expandable Arrow" - ) - } -} - @Composable fun VideoTitle( text: String, @@ -1149,6 +1120,49 @@ fun DatesShiftedSnackBar( } } +@Composable +fun CourseMessage( + modifier: Modifier = Modifier, + icon: Painter, + message: String, + action: String? = null, + onActionClick: () -> Unit = {} +) { + Column { + Row( + modifier + .semantics(mergeDescendants = true) {} + .noRippleClickable(onActionClick) + ) { + Icon( + painter = icon, + contentDescription = null, + modifier = Modifier.align(Alignment.CenterVertically), + tint = MaterialTheme.appColors.textPrimary + ) + Column(Modifier.padding(start = 12.dp)) { + Text( + text = message, + color = MaterialTheme.appColors.textPrimary, + style = MaterialTheme.appTypography.labelLarge + ) + if (action != null) { + Text( + text = action, + modifier = Modifier.padding(top = 4.dp), + color = MaterialTheme.appColors.textPrimary, + style = MaterialTheme.appTypography.labelLarge.copy(textDecoration = TextDecoration.Underline) + ) + } + } + } + Divider( + color = MaterialTheme.appColors.divider + ) + } + +} + @Preview(uiMode = Configuration.UI_MODE_NIGHT_NO) @Preview(uiMode = Configuration.UI_MODE_NIGHT_YES) @Composable @@ -1208,29 +1222,14 @@ private fun NavigationUnitsButtonsWithNextPreview() { @Preview(uiMode = Configuration.UI_MODE_NIGHT_NO) @Preview(uiMode = Configuration.UI_MODE_NIGHT_YES) @Composable -private fun SequentialItemPreview() { - OpenEdXTheme { - Surface(color = MaterialTheme.appColors.background) { - SequentialItem(block = mockChapterBlock, onClick = {}) - } - } -} - -@Preview(uiMode = Configuration.UI_MODE_NIGHT_NO) -@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES) -@Composable -private fun CourseHeaderPreview() { +private fun CourseSectionCardPreview() { OpenEdXTheme { Surface(color = MaterialTheme.appColors.background) { - CourseImageHeader( - modifier = Modifier - .fillMaxWidth() - .height(200.dp) - .padding(6.dp), - apiHostUrl = "", - courseCertificate = Certificate(""), - courseImage = "", - courseName = "" + CourseSectionCard( + mockChapterBlock, + DownloadedState.DOWNLOADED, + onItemClick = {}, + onDownloadClick = {} ) } } @@ -1287,6 +1286,27 @@ private fun OfflineQueueCardPreview() { } } +@Preview(uiMode = Configuration.UI_MODE_NIGHT_NO) +@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES) +@Composable +private fun CourseMessagePreview() { + OpenEdXTheme { + Surface(color = MaterialTheme.appColors.background) { + CourseMessage( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 24.dp, vertical = 12.dp), + icon = painterResource(R.drawable.ic_course_certificate), + message = stringResource( + R.string.course_you_earned_certificate, + "Demo Course" + ), + action = stringResource(R.string.course_view_certificate), + ) + } + } +} + private val mockChapterBlock = Block( id = "id", blockId = "blockId", diff --git a/course/src/main/res/drawable/ic_course_certificate.xml b/course/src/main/res/drawable/ic_course_certificate.xml new file mode 100644 index 000000000..53ca91779 --- /dev/null +++ b/course/src/main/res/drawable/ic_course_certificate.xml @@ -0,0 +1,9 @@ + + + diff --git a/course/src/main/res/drawable/ic_course_completed_mark.xml b/course/src/main/res/drawable/ic_course_completed_mark.xml deleted file mode 100644 index bf3307778..000000000 --- a/course/src/main/res/drawable/ic_course_completed_mark.xml +++ /dev/null @@ -1,31 +0,0 @@ - - - - - - - - diff --git a/course/src/main/res/values-uk/strings.xml b/course/src/main/res/values-uk/strings.xml index ffbf7c459..14f3487c4 100644 --- a/course/src/main/res/values-uk/strings.xml +++ b/course/src/main/res/values-uk/strings.xml @@ -5,12 +5,8 @@ Одиниці курсу Підрозділи курсу Відео - Ви успішно пройшли курс! Тепер ви можете отримати сертифікат - Ви успішно пройшли курс - Вітаємо! + Вітаємо, ви отримали сертифікат про проходження курсу \"%s\". Переглянути сертифікат - Ви можете отримати сертифікат після проходження курсу (заробіть необхідну оцінку) - Отримати сертифікат Назад Попередня одиниця Далі diff --git a/course/src/main/res/values/strings.xml b/course/src/main/res/values/strings.xml index 6e5f67254..596a6a77a 100644 --- a/course/src/main/res/values/strings.xml +++ b/course/src/main/res/values/strings.xml @@ -5,12 +5,8 @@ Course units Course subsections Videos - You have passed the course! Now you can get the certificate - You’ve completed the course - Congratulations! - View the certificate - You can get a certificate after completing the course (earn required grade) - Get the certificate + Congratulations, you have earned this course certificate in \"%s\". + View certificate Prev Previous Unit Next From 0bd69bdf1af6d828899950d53cea0a5350245421 Mon Sep 17 00:00:00 2001 From: Farhan Arshad <43750646+farhan-arshad-dev@users.noreply.github.com> Date: Thu, 23 May 2024 14:43:44 +0500 Subject: [PATCH 08/18] chore: enhance app theme capability for prod edX theme/branding (#262) chore: enhance app theme capability for prod edX theme/branding - Integrate Program config updates - theming/branding code improvements for light and dark modes - Force dark mode for the WebView (beta version) - No major change in the Open edX theme fixes: LEARNER-9783 --- app/src/main/res/layout/fragment_main.xml | 5 +- .../logistration/LogistrationFragment.kt | 5 +- .../presentation/signin/compose/SignInView.kt | 12 +++-- .../presentation/signup/compose/SignUpView.kt | 4 +- .../openedx/auth/presentation/ui/AuthUI.kt | 13 +++-- .../auth/presentation/ui/SocialAuthView.kt | 11 ++-- auth/src/main/res/values-uk/strings.xml | 2 - auth/src/main/res/values/strings.xml | 4 +- build.gradle | 2 + core/build.gradle | 2 + .../org/openedx/core/config/ProgramConfig.kt | 2 +- .../org/openedx/core/extension/ViewExt.kt | 25 +++++++++ .../dialog/appreview/AppReviewUI.kt | 4 +- .../global/app_upgrade/AppUpdateUI.kt | 6 +-- .../java/org/openedx/core/ui/ComposeCommon.kt | 28 ++++++---- .../org/openedx/core/ui/WebContentScreen.kt | 2 + .../org/openedx/core/ui/theme/AppColors.kt | 21 ++++++-- .../java/org/openedx/core/ui/theme/Theme.kt | 42 ++++++++++++--- .../java/org/openedx/core/ui/theme/Type.kt | 2 - .../org/openedx/core/ui/theme/Colors.kt | 52 +++++++++++++++---- .../presentation/ChapterEndFragmentDialog.kt | 16 +++--- .../outline/CourseOutlineScreen.kt | 4 +- .../course/presentation/ui/CourseUI.kt | 14 +++-- .../course/presentation/ui/CourseVideosUI.kt | 1 + .../unit/NotSupportedUnitFragment.kt | 4 +- .../unit/html/HtmlUnitFragment.kt | 5 ++ .../presentation/catalog/CatalogWebView.kt | 5 +- .../detail/CourseDetailsFragment.kt | 4 ++ .../threads/DiscussionThreadsFragment.kt | 8 +-- .../presentation/ui/DiscussionUI.kt | 3 +- .../presentation/edit/EditProfileFragment.kt | 26 ++++++---- .../compose/ManageAccountView.kt | 2 +- .../profile/compose/ProfileView.kt | 2 +- .../whatsnew/presentation/ui/WhatsNewUI.kt | 10 ++-- 34 files changed, 246 insertions(+), 102 deletions(-) diff --git a/app/src/main/res/layout/fragment_main.xml b/app/src/main/res/layout/fragment_main.xml index eb6f37a6f..89cf2914a 100644 --- a/app/src/main/res/layout/fragment_main.xml +++ b/app/src/main/res/layout/fragment_main.xml @@ -14,11 +14,11 @@ app:layout_constraintStart_toStartOf="parent" app:layout_constraintTop_toTopOf="parent" /> - - - \ No newline at end of file + diff --git a/auth/src/main/java/org/openedx/auth/presentation/logistration/LogistrationFragment.kt b/auth/src/main/java/org/openedx/auth/presentation/logistration/LogistrationFragment.kt index 738364c34..ae3d2365e 100644 --- a/auth/src/main/java/org/openedx/auth/presentation/logistration/LogistrationFragment.kt +++ b/auth/src/main/java/org/openedx/auth/presentation/logistration/LogistrationFragment.kt @@ -33,6 +33,7 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.semantics.semantics import androidx.compose.ui.semantics.testTagsAsResourceId import androidx.compose.ui.text.input.TextFieldValue +import androidx.compose.ui.text.style.TextDecoration import androidx.compose.ui.tooling.preview.Devices import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp @@ -131,7 +132,6 @@ private fun LogistrationScreen( LogistrationLogoView() Text( text = stringResource(id = R.string.pre_auth_title), - color = MaterialTheme.appColors.textPrimary, style = MaterialTheme.appTypography.headlineSmall, modifier = Modifier .testTag("txt_screen_title") @@ -177,7 +177,8 @@ private fun LogistrationScreen( }, text = stringResource(id = R.string.pre_auth_explore_all_courses), color = MaterialTheme.appColors.primary, - style = MaterialTheme.appTypography.labelLarge + style = MaterialTheme.appTypography.labelLarge, + textDecoration = TextDecoration.Underline ) Spacer(modifier = Modifier.weight(1f)) diff --git a/auth/src/main/java/org/openedx/auth/presentation/signin/compose/SignInView.kt b/auth/src/main/java/org/openedx/auth/presentation/signin/compose/SignInView.kt index 77e290994..37309cadf 100644 --- a/auth/src/main/java/org/openedx/auth/presentation/signin/compose/SignInView.kt +++ b/auth/src/main/java/org/openedx/auth/presentation/signin/compose/SignInView.kt @@ -49,6 +49,7 @@ import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.text.input.PasswordVisualTransformation import androidx.compose.ui.text.input.TextFieldValue +import androidx.compose.ui.text.style.TextDecoration import androidx.compose.ui.tooling.preview.Devices import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.Dp @@ -195,7 +196,8 @@ internal fun LoginScreen( modifier = Modifier.testTag("txt_${state.agreement.name}"), fullText = linkedText.text, hyperLinks = linkedText.links, - linkTextColor = MaterialTheme.appColors.primary, + linkTextColor = MaterialTheme.appColors.textHyperLink, + linkTextDecoration = TextDecoration.Underline, action = { link -> onEvent(AuthEvent.OpenLink(linkedText.links, link)) }, @@ -264,7 +266,7 @@ private fun AuthForm( onEvent(AuthEvent.ForgotPasswordClick) }, text = stringResource(id = R.string.auth_forgot_password), - color = MaterialTheme.appColors.primary, + color = MaterialTheme.appColors.info_variant, style = MaterialTheme.appTypography.labelLarge ) } @@ -275,6 +277,8 @@ private fun AuthForm( OpenEdXButton( modifier = buttonWidth.testTag("btn_sign_in"), text = stringResource(id = coreR.string.core_sign_in), + textColor = MaterialTheme.appColors.primaryButtonText, + backgroundColor = MaterialTheme.appColors.secondaryButtonBackground, onClick = { onEvent(AuthEvent.SignIn(login = login, password = password)) } @@ -323,8 +327,10 @@ private fun PasswordTextField( onValueChanged(it.text.trim()) }, colors = TextFieldDefaults.outlinedTextFieldColors( + textColor = MaterialTheme.appColors.textFieldText, + backgroundColor = MaterialTheme.appColors.textFieldBackground, unfocusedBorderColor = MaterialTheme.appColors.textFieldBorder, - backgroundColor = MaterialTheme.appColors.textFieldBackground + cursorColor = MaterialTheme.appColors.textFieldText, ), shape = MaterialTheme.appShapes.textFieldShape, placeholder = { diff --git a/auth/src/main/java/org/openedx/auth/presentation/signup/compose/SignUpView.kt b/auth/src/main/java/org/openedx/auth/presentation/signup/compose/SignUpView.kt index 2e2180d83..42fd894df 100644 --- a/auth/src/main/java/org/openedx/auth/presentation/signup/compose/SignUpView.kt +++ b/auth/src/main/java/org/openedx/auth/presentation/signup/compose/SignUpView.kt @@ -329,7 +329,7 @@ internal fun SignUpView( modifier = Modifier .testTag("txt_sign_up_title") .fillMaxWidth(), - text = stringResource(id = R.string.auth_sign_up), + text = stringResource(id = coreR.string.core_register), color = MaterialTheme.appColors.textPrimary, style = MaterialTheme.appTypography.displaySmall ) @@ -437,6 +437,8 @@ internal fun SignUpView( OpenEdXButton( modifier = buttonWidth.testTag("btn_create_account"), text = stringResource(id = R.string.auth_create_account), + textColor = MaterialTheme.appColors.primaryButtonText, + backgroundColor = MaterialTheme.appColors.secondaryButtonBackground, onClick = { showErrorMap.clear() onRegisterClick(AuthType.PASSWORD) diff --git a/auth/src/main/java/org/openedx/auth/presentation/ui/AuthUI.kt b/auth/src/main/java/org/openedx/auth/presentation/ui/AuthUI.kt index 4f98ea50c..90fb91ee1 100644 --- a/auth/src/main/java/org/openedx/auth/presentation/ui/AuthUI.kt +++ b/auth/src/main/java/org/openedx/auth/presentation/ui/AuthUI.kt @@ -44,6 +44,7 @@ import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.text.input.PasswordVisualTransformation import androidx.compose.ui.text.input.TextFieldValue import androidx.compose.ui.text.input.VisualTransformation +import androidx.compose.ui.text.style.TextDecoration import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import org.openedx.auth.R @@ -170,7 +171,8 @@ fun OptionalFields( HyperlinkText( fullText = linkedText.text, hyperLinks = linkedText.links, - linkTextColor = MaterialTheme.appColors.primary, + linkTextColor = MaterialTheme.appColors.textHyperLink, + linkTextDecoration = TextDecoration.Underline, action = { hyperLinkAction?.invoke(linkedText.links, it) }, @@ -250,8 +252,10 @@ fun LoginTextField( onValueChanged(it.text.trim()) }, colors = TextFieldDefaults.outlinedTextFieldColors( + textColor = MaterialTheme.appColors.textFieldText, + backgroundColor = MaterialTheme.appColors.textFieldBackground, unfocusedBorderColor = MaterialTheme.appColors.textFieldBorder, - backgroundColor = MaterialTheme.appColors.textFieldBackground + cursorColor = MaterialTheme.appColors.textFieldText, ), shape = MaterialTheme.appShapes.textFieldShape, placeholder = { @@ -332,8 +336,11 @@ fun InputRegistrationField( } }, colors = TextFieldDefaults.outlinedTextFieldColors( + textColor = MaterialTheme.appColors.textFieldText, + backgroundColor = MaterialTheme.appColors.textFieldBackground, + focusedBorderColor = MaterialTheme.appColors.textFieldBorder, unfocusedBorderColor = MaterialTheme.appColors.textFieldBorder, - backgroundColor = MaterialTheme.appColors.textFieldBackground + cursorColor = MaterialTheme.appColors.textFieldText, ), shape = MaterialTheme.appShapes.textFieldShape, placeholder = { diff --git a/auth/src/main/java/org/openedx/auth/presentation/ui/SocialAuthView.kt b/auth/src/main/java/org/openedx/auth/presentation/ui/SocialAuthView.kt index 336c09f8f..028439290 100644 --- a/auth/src/main/java/org/openedx/auth/presentation/ui/SocialAuthView.kt +++ b/auth/src/main/java/org/openedx/auth/presentation/ui/SocialAuthView.kt @@ -45,7 +45,7 @@ internal fun SocialAuthView( .testTag("btn_google_auth") .padding(top = 24.dp) .fillMaxWidth(), - backgroundColor = MaterialTheme.appColors.background, + backgroundColor = MaterialTheme.appColors.authGoogleButtonBackground, borderColor = MaterialTheme.appColors.primary, textColor = Color.Unspecified, onClick = { @@ -62,7 +62,8 @@ internal fun SocialAuthView( modifier = Modifier .testTag("txt_google_auth") .padding(start = 10.dp), - text = stringResource(id = stringRes) + text = stringResource(id = stringRes), + color = MaterialTheme.appColors.primaryButtonBorderedText, ) } } @@ -87,13 +88,13 @@ internal fun SocialAuthView( Icon( painter = painterResource(id = R.drawable.ic_auth_facebook), contentDescription = null, - tint = MaterialTheme.appColors.buttonText, + tint = MaterialTheme.appColors.primaryButtonText, ) Text( modifier = Modifier .testTag("txt_facebook_auth") .padding(start = 10.dp), - color = MaterialTheme.appColors.buttonText, + color = MaterialTheme.appColors.primaryButtonText, text = stringResource(id = stringRes) ) } @@ -125,7 +126,7 @@ internal fun SocialAuthView( modifier = Modifier .testTag("txt_microsoft_auth") .padding(start = 10.dp), - color = MaterialTheme.appColors.buttonText, + color = MaterialTheme.appColors.primaryButtonText, text = stringResource(id = stringRes) ) } diff --git a/auth/src/main/res/values-uk/strings.xml b/auth/src/main/res/values-uk/strings.xml index c2c34abef..9c1a1aa69 100644 --- a/auth/src/main/res/values-uk/strings.xml +++ b/auth/src/main/res/values-uk/strings.xml @@ -5,7 +5,6 @@ Електронна пошта Неправильна E-mail адреса Пароль занадто короткий - Ласкаво просимо! Будь ласка, авторизуйтесь, щоб продовжити. Показати додаткові поля Приховати додаткові поля Створити акаунт @@ -15,6 +14,5 @@ Перевірте свою електронну пошту Ми надіслали інструкції щодо відновлення пароля на вашу електронну пошту %s Введіть пароль - Створити новий аккаунт. diff --git a/auth/src/main/res/values/strings.xml b/auth/src/main/res/values/strings.xml index 4f8ce12d8..49b3e0bbd 100644 --- a/auth/src/main/res/values/strings.xml +++ b/auth/src/main/res/values/strings.xml @@ -11,7 +11,7 @@ Email or Username Invalid email or username Password is too short - Welcome back! Please authorize to continue. + Welcome back! Sign in to access your courses. Show optional fields Hide optional fields Create account @@ -23,7 +23,7 @@ username@domain.com Enter email or username Enter password - Create new account. + Create an account to start learning today! Complete your registration Sign in with Google Sign in with Facebook diff --git a/build.gradle b/build.gradle index ef9ca662c..250f56863 100644 --- a/build.gradle +++ b/build.gradle @@ -56,6 +56,8 @@ ext { extented_spans_version = "1.3.0" + webkit_version = "1.11.0" + configHelper = new ConfigHelper(projectDir, getCurrentFlavor()) //testing diff --git a/core/build.gradle b/core/build.gradle index f1f091823..f69d633cb 100644 --- a/core/build.gradle +++ b/core/build.gradle @@ -154,6 +154,8 @@ dependencies { //Play In-App Review api "com.google.android.play:review-ktx:$in_app_review" + api "androidx.webkit:webkit:$webkit_version" + testImplementation 'junit:junit:4.13.2' androidTestImplementation 'androidx.test.ext:junit:1.1.5' androidTestImplementation 'androidx.test.espresso:espresso-core:3.5.1' diff --git a/core/src/main/java/org/openedx/core/config/ProgramConfig.kt b/core/src/main/java/org/openedx/core/config/ProgramConfig.kt index 55714dadc..c553f8997 100644 --- a/core/src/main/java/org/openedx/core/config/ProgramConfig.kt +++ b/core/src/main/java/org/openedx/core/config/ProgramConfig.kt @@ -14,7 +14,7 @@ data class ProgramConfig( } data class ProgramWebViewConfig( - @SerializedName("PROGRAM_URL") + @SerializedName("BASE_URL") val programUrl: String = "", @SerializedName("PROGRAM_DETAIL_URL_TEMPLATE") val programDetailUrlTemplate: String = "", diff --git a/core/src/main/java/org/openedx/core/extension/ViewExt.kt b/core/src/main/java/org/openedx/core/extension/ViewExt.kt index 9146a3159..ebd007d3d 100644 --- a/core/src/main/java/org/openedx/core/extension/ViewExt.kt +++ b/core/src/main/java/org/openedx/core/extension/ViewExt.kt @@ -3,12 +3,15 @@ package org.openedx.core.extension import android.content.Context import android.content.res.Resources import android.graphics.Rect +import android.os.Build import android.util.DisplayMetrics import android.view.View import android.view.ViewGroup import android.webkit.WebView import android.widget.Toast import androidx.fragment.app.DialogFragment +import androidx.webkit.WebSettingsCompat +import androidx.webkit.WebViewFeature import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.launch import org.openedx.core.system.AppCookieManager @@ -61,3 +64,25 @@ fun WebView.loadUrl(url: String, scope: CoroutineScope, cookieManager: AppCookie loadUrl(url) } } + +fun WebView.applyDarkModeIfEnabled(isDarkTheme: Boolean) { + if (isDarkTheme && WebViewFeature.isFeatureSupported(WebViewFeature.FORCE_DARK)) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + settings.setAlgorithmicDarkeningAllowed(true) + } else { + // Switch WebView to dark mode; uses default dark theme + if (WebViewFeature.isFeatureSupported(WebViewFeature.FORCE_DARK)) { + WebSettingsCompat.setForceDark( + settings, + WebSettingsCompat.FORCE_DARK_ON + ) + } + if (WebViewFeature.isFeatureSupported(WebViewFeature.FORCE_DARK_STRATEGY)) { + WebSettingsCompat.setForceDarkStrategy( + settings, + WebSettingsCompat.DARK_STRATEGY_PREFER_WEB_THEME_OVER_USER_AGENT_DARKENING + ) + } + } + } +} diff --git a/core/src/main/java/org/openedx/core/presentation/dialog/appreview/AppReviewUI.kt b/core/src/main/java/org/openedx/core/presentation/dialog/appreview/AppReviewUI.kt index e2d6a471f..b924cd543 100644 --- a/core/src/main/java/org/openedx/core/presentation/dialog/appreview/AppReviewUI.kt +++ b/core/src/main/java/org/openedx/core/presentation/dialog/appreview/AppReviewUI.kt @@ -320,8 +320,8 @@ fun DefaultTextButton( val textColor: Color val backgroundColor: Color if (isEnabled) { - textColor = MaterialTheme.appColors.buttonText - backgroundColor = MaterialTheme.appColors.buttonBackground + textColor = MaterialTheme.appColors.primaryButtonText + backgroundColor = MaterialTheme.appColors.primaryButtonBackground } else { textColor = MaterialTheme.appColors.inactiveButtonText backgroundColor = MaterialTheme.appColors.inactiveButtonBackground diff --git a/core/src/main/java/org/openedx/core/presentation/global/app_upgrade/AppUpdateUI.kt b/core/src/main/java/org/openedx/core/presentation/global/app_upgrade/AppUpdateUI.kt index f0502b49d..3f8dd6fa9 100644 --- a/core/src/main/java/org/openedx/core/presentation/global/app_upgrade/AppUpdateUI.kt +++ b/core/src/main/java/org/openedx/core/presentation/global/app_upgrade/AppUpdateUI.kt @@ -292,7 +292,7 @@ fun DefaultTextButton( .testTag("btn_primary") .height(42.dp), colors = ButtonDefaults.buttonColors( - backgroundColor = MaterialTheme.appColors.buttonBackground + backgroundColor = MaterialTheme.appColors.primaryButtonBackground ), elevation = null, shape = MaterialTheme.appShapes.navigationButtonShape, @@ -305,7 +305,7 @@ fun DefaultTextButton( Text( modifier = Modifier.testTag("txt_primary"), text = text, - color = MaterialTheme.appColors.buttonText, + color = MaterialTheme.appColors.primaryButtonText, style = MaterialTheme.appTypography.labelLarge ) } @@ -401,4 +401,4 @@ private fun AppUpgradeRecommendDialogPreview() { onUpdateClick = {} ) } -} \ No newline at end of file +} diff --git a/core/src/main/java/org/openedx/core/ui/ComposeCommon.kt b/core/src/main/java/org/openedx/core/ui/ComposeCommon.kt index 3b97742f1..212014177 100644 --- a/core/src/main/java/org/openedx/core/ui/ComposeCommon.kt +++ b/core/src/main/java/org/openedx/core/ui/ComposeCommon.kt @@ -434,7 +434,7 @@ fun HyperlinkText( append(fullText) addStyle( style = SpanStyle( - color = MaterialTheme.appColors.textPrimary, + color = MaterialTheme.appColors.textPrimaryLight, fontSize = fontSize ), start = 0, @@ -450,7 +450,7 @@ fun HyperlinkText( color = linkTextColor, fontSize = fontSize, fontWeight = linkTextFontWeight, - textDecoration = linkTextDecoration + textDecoration = linkTextDecoration, ), start = startIndex, end = endIndex @@ -635,7 +635,8 @@ fun SheetContent( .padding(10.dp), textAlign = TextAlign.Center, style = MaterialTheme.appTypography.titleMedium, - text = title + text = title, + color = MaterialTheme.appColors.onBackground ) SearchBarStateless( modifier = Modifier @@ -667,6 +668,7 @@ fun SheetContent( onItemClick(item) } .padding(vertical = 12.dp), + color = MaterialTheme.appColors.onBackground, text = item.name, style = MaterialTheme.appTypography.bodyLarge, textAlign = TextAlign.Center @@ -1049,8 +1051,9 @@ fun OpenEdXButton( text: String = "", onClick: () -> Unit, enabled: Boolean = true, - backgroundColor: Color = MaterialTheme.appColors.buttonBackground, - content: (@Composable RowScope.() -> Unit)? = null, + textColor: Color = MaterialTheme.appColors.primaryButtonText, + backgroundColor: Color = MaterialTheme.appColors.primaryButtonBackground, + content: (@Composable RowScope.() -> Unit)? = null ) { Button( modifier = Modifier @@ -1068,7 +1071,7 @@ fun OpenEdXButton( Text( modifier = Modifier.testTag("txt_${text.tagId()}"), text = text, - color = MaterialTheme.appColors.buttonText, + color = textColor, style = MaterialTheme.appTypography.labelLarge ) } else { @@ -1084,6 +1087,7 @@ fun OpenEdXOutlinedButton( borderColor: Color, textColor: Color, text: String = "", + enabled: Boolean = true, onClick: () -> Unit, content: (@Composable RowScope.() -> Unit)? = null, ) { @@ -1093,6 +1097,7 @@ fun OpenEdXOutlinedButton( .then(modifier) .height(42.dp), onClick = onClick, + enabled = enabled, border = BorderStroke(1.dp, borderColor), shape = MaterialTheme.appShapes.buttonShape, colors = ButtonDefaults.outlinedButtonColors(backgroundColor = backgroundColor) @@ -1163,7 +1168,9 @@ fun ConnectionErrorView( modifier = Modifier .widthIn(Dp.Unspecified, 162.dp), text = stringResource(id = R.string.core_reload), - onClick = onReloadClick + textColor = MaterialTheme.appColors.primaryButtonText, + backgroundColor = MaterialTheme.appColors.secondaryButtonBackground, + onClick = onReloadClick, ) } } @@ -1180,6 +1187,8 @@ fun AuthButtonsPanel( .width(0.dp) .weight(1f), text = stringResource(id = R.string.core_register), + textColor = MaterialTheme.appColors.primaryButtonText, + backgroundColor = MaterialTheme.appColors.secondaryButtonBackground, onClick = { onRegisterClick() } ) @@ -1190,8 +1199,9 @@ fun AuthButtonsPanel( .padding(start = 16.dp), text = stringResource(id = R.string.core_sign_in), onClick = { onSignInClick() }, - borderColor = MaterialTheme.appColors.textFieldBorder, - textColor = MaterialTheme.appColors.primary + textColor = MaterialTheme.appColors.secondaryButtonBorderedText, + backgroundColor = MaterialTheme.appColors.secondaryButtonBorderedBackground, + borderColor = MaterialTheme.appColors.secondaryButtonBorder, ) } } diff --git a/core/src/main/java/org/openedx/core/ui/WebContentScreen.kt b/core/src/main/java/org/openedx/core/ui/WebContentScreen.kt index c9c7c4ba1..06aa70ea2 100644 --- a/core/src/main/java/org/openedx/core/ui/WebContentScreen.kt +++ b/core/src/main/java/org/openedx/core/ui/WebContentScreen.kt @@ -37,6 +37,7 @@ import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import androidx.compose.ui.viewinterop.AndroidView import androidx.compose.ui.zIndex +import org.openedx.core.extension.applyDarkModeIfEnabled import org.openedx.core.extension.isEmailValid import org.openedx.core.extension.replaceLinkTags import org.openedx.core.ui.theme.appColors @@ -195,6 +196,7 @@ private fun WebViewContent( contentUrl?.let { loadUrl(it) } + applyDarkModeIfEnabled(isDarkTheme) } }, update = { webView -> 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 af901e153..625c52b27 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 @@ -8,6 +8,8 @@ data class AppColors( val textPrimary: Color, val textPrimaryVariant: Color, + val textPrimaryLight: Color, + val textHyperLink: Color, val textSecondary: Color, val textDark: Color, val textAccent: Color, @@ -19,9 +21,18 @@ data class AppColors( val textFieldText: Color, val textFieldHint: Color, - val buttonBackground: Color, - val buttonSecondaryBackground: Color, - val buttonText: Color, + val primaryButtonBackground: Color, + val primaryButtonText: Color, + val primaryButtonBorder: Color, + val primaryButtonBorderedText: Color, + + // The default secondary button styling is identical to the primary button styling. + // However, you can customize it if your brand utilizes two accent colors. + val secondaryButtonBackground: Color, + val secondaryButtonText: Color, + val secondaryButtonBorder: Color, + val secondaryButtonBorderedBackground: Color, + val secondaryButtonBorderedText: Color, val cardViewBackground: Color, val cardViewBorder: Color, @@ -31,6 +42,9 @@ data class AppColors( val bottomSheetToggle: Color, val warning: Color, val info: Color, + val info_variant: Color, + val onWarning: Color, + val onInfo: Color, val rateStars: Color, val inactiveButtonBackground: Color, @@ -44,6 +58,7 @@ data class AppColors( val datesSectionBarNextWeek: Color, val datesSectionBarUpcoming: Color, + val authGoogleButtonBackground: Color, val authFacebookButtonBackground: Color, val authMicrosoftButtonBackground: Color, 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 52f89305b..88c973105 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 @@ -27,10 +27,12 @@ private val DarkColorPalette = AppColors( ), textPrimary = dark_text_primary, textPrimaryVariant = dark_text_primary_variant, + textPrimaryLight = dark_text_primary_light, textSecondary = dark_text_secondary, textDark = dark_text_dark, textAccent = dark_text_accent, textWarning = dark_text_warning, + textHyperLink = dark_text_hyper_link, textFieldBackground = dark_text_field_background, textFieldBackgroundVariant = dark_text_field_background_variant, @@ -38,9 +40,16 @@ private val DarkColorPalette = AppColors( textFieldText = dark_text_field_text, textFieldHint = dark_text_field_hint, - buttonBackground = dark_button_background, - buttonSecondaryBackground = dark_button_secondary_background, - buttonText = dark_button_text, + primaryButtonBackground = dark_primary_button_background, + primaryButtonText = dark_primary_button_text, + primaryButtonBorder = dark_primary_button_border, + primaryButtonBorderedText = dark_primary_button_bordered_text, + + secondaryButtonBackground = dark_secondary_button_background, + secondaryButtonText = dark_secondary_button_text, + secondaryButtonBorder = dark_secondary_button_border, + secondaryButtonBorderedBackground = dark_secondary_button_bordered_background, + secondaryButtonBorderedText = dark_secondary_button_bordered_text, cardViewBackground = dark_card_view_background, cardViewBorder = dark_card_view_border, @@ -51,10 +60,13 @@ private val DarkColorPalette = AppColors( warning = dark_warning, info = dark_info, + info_variant = dark_info_variant, + onWarning = dark_onWarning, + onInfo = dark_onInfo, rateStars = dark_rate_stars, inactiveButtonBackground = dark_inactive_button_background, - inactiveButtonText = dark_button_text, + inactiveButtonText = dark_primary_button_text, successGreen = dark_success_green, @@ -64,6 +76,7 @@ private val DarkColorPalette = AppColors( datesSectionBarNextWeek = dark_dates_section_bar_next_week, datesSectionBarUpcoming = dark_dates_section_bar_upcoming, + authGoogleButtonBackground = dark_auth_google_button_background, authFacebookButtonBackground = dark_auth_facebook_button_background, authMicrosoftButtonBackground = dark_auth_microsoft_button_background, @@ -101,10 +114,12 @@ private val LightColorPalette = AppColors( ), textPrimary = light_text_primary, textPrimaryVariant = light_text_primary_variant, + textPrimaryLight = light_text_primary_light, textSecondary = light_text_secondary, textDark = light_text_dark, textAccent = light_text_accent, textWarning = light_text_warning, + textHyperLink = light_text_hyper_link, textFieldBackground = light_text_field_background, textFieldBackgroundVariant = light_text_field_background_variant, @@ -112,9 +127,16 @@ private val LightColorPalette = AppColors( textFieldText = light_text_field_text, textFieldHint = light_text_field_hint, - buttonBackground = light_button_background, - buttonSecondaryBackground = light_button_secondary_background, - buttonText = light_button_text, + primaryButtonBackground = light_primary_button_background, + primaryButtonText = light_primary_button_text, + primaryButtonBorder = light_primary_button_border, + primaryButtonBorderedText = light_primary_button_bordered_text, + + secondaryButtonBackground = light_secondary_button_background, + secondaryButtonText = light_secondary_button_text, + secondaryButtonBorder = light_secondary_button_border, + secondaryButtonBorderedBackground = light_secondary_button_bordered_background, + secondaryButtonBorderedText = light_secondary_button_bordered_text, cardViewBackground = light_card_view_background, cardViewBorder = light_card_view_border, @@ -125,10 +147,13 @@ private val LightColorPalette = AppColors( warning = light_warning, info = light_info, + info_variant = light_info_variant, + onWarning = light_onWarning, + onInfo = light_onInfo, rateStars = light_rate_stars, inactiveButtonBackground = light_inactive_button_background, - inactiveButtonText = light_button_text, + inactiveButtonText = light_primary_button_text, successGreen = light_success_green, @@ -138,6 +163,7 @@ private val LightColorPalette = AppColors( datesSectionBarNextWeek = light_dates_section_bar_next_week, datesSectionBarUpcoming = light_dates_section_bar_upcoming, + authGoogleButtonBackground = light_auth_google_button_background, authFacebookButtonBackground = light_auth_facebook_button_background, authMicrosoftButtonBackground = light_auth_microsoft_button_background, diff --git a/core/src/main/java/org/openedx/core/ui/theme/Type.kt b/core/src/main/java/org/openedx/core/ui/theme/Type.kt index edd2afcc7..0160196f9 100644 --- a/core/src/main/java/org/openedx/core/ui/theme/Type.kt +++ b/core/src/main/java/org/openedx/core/ui/theme/Type.kt @@ -34,7 +34,6 @@ data class AppTypography( val fontFamily = FontFamily( Font(R.font.regular, FontWeight.Black, FontStyle.Normal), Font(R.font.bold, FontWeight.Bold, FontStyle.Normal), - Font(R.font.bold, FontWeight.Bold, FontStyle.Normal), Font(R.font.extra_light, FontWeight.Light, FontStyle.Normal), Font(R.font.light, FontWeight.Light, FontStyle.Normal), Font(R.font.medium, FontWeight.Medium, FontStyle.Normal), @@ -43,7 +42,6 @@ val fontFamily = FontFamily( Font(R.font.thin, FontWeight.Thin, FontStyle.Normal), ) - internal val LocalTypography = staticCompositionLocalOf { AppTypography( displayLarge = TextStyle( 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 f7ba7ab36..83f655df6 100644 --- a/core/src/openedx/org/openedx/core/ui/theme/Colors.kt +++ b/core/src/openedx/org/openedx/core/ui/theme/Colors.kt @@ -10,24 +10,38 @@ val light_background = Color.White val light_surface = Color(0xFFF7F7F8) val light_error = Color(0xFFFF3D71) val light_onPrimary = Color.White -val light_onSecondary = Color.Black +val light_onSecondary = Color.White val light_onBackground = Color.Black val light_onSurface = Color.Black val light_onError = Color.White +val light_onWarning = Color.White +val light_onInfo = Color.White +val light_info_variant = Color(0xFF3C68FF) val light_text_primary = Color(0xFF212121) val light_text_primary_variant = Color(0xFF3D4964) +val light_text_primary_light = light_text_primary val light_text_secondary = Color(0xFFB3B3B3) val light_text_dark = Color(0xFF19212F) val light_text_accent = Color(0xFF3C68FF) -val light_text_warning= Color(0xFF19212F) +val light_text_warning = Color(0xFF19212F) val light_text_field_background = Color(0xFFF7F7F8) val light_text_field_background_variant = Color.White val light_text_field_border = Color(0xFF97A5BB) val light_text_field_text = Color(0xFF3D4964) val light_text_field_hint = Color(0xFF97A5BB) -val light_button_background = Color(0xFF3C68FF) -val light_button_secondary_background = Color(0xFF79889F) -val light_button_text = Color.White +val light_text_hyper_link = Color(0xFF3C68FF) + +val light_primary_button_background = Color(0xFF3C68FF) +val light_primary_button_border = Color(0xFF97A5BB) +val light_primary_button_text = Color.White +val light_primary_button_bordered_text = Color(0xFF3C68FF) + +val light_secondary_button_background = light_primary_button_background +val light_secondary_button_text = light_primary_button_text +val light_secondary_button_border = light_primary_button_border +val light_secondary_button_bordered_background = Color.White +val light_secondary_button_bordered_text = light_primary_button_bordered_text + val light_card_view_background = Color(0xFFF9FAFB) val light_card_view_border = Color(0xFFCCD4E0) val light_divider = Color(0xFFCCD4E0) @@ -44,6 +58,7 @@ val light_dates_section_bar_today = light_info val light_dates_section_bar_this_week = light_text_primary_variant val light_dates_section_bar_next_week = light_text_field_border val light_dates_section_bar_upcoming = Color(0xFFCCD4E0) +val light_auth_google_button_background = Color.White val light_auth_facebook_button_background = Color(0xFF0866FF) val light_auth_microsoft_button_background = Color(0xFA000000) val light_component_horizontal_progress_completed_and_selected = Color(0xFF30a171) @@ -68,31 +83,45 @@ val dark_background = Color(0xFF19212F) val dark_surface = Color(0xFF273346) val dark_error = Color(0xFFFF3D71) val dark_onPrimary = Color.Black -val dark_onSecondary = Color.Black +val dark_onSecondary = Color.White val dark_onBackground = Color.White val dark_onSurface = Color.White val dark_onError = Color.Black val dark_text_primary = Color.White -val dark_text_primary_variant = Color(0xFF79889F) +val dark_text_primary_light = dark_text_primary +val dark_text_primary_variant = Color.White val dark_text_secondary = Color(0xFFB3B3B3) val dark_text_dark = Color.White val dark_text_accent = Color(0xFF879FF5) -val dark_text_warning= Color(0xFF19212F) +val dark_text_warning = Color(0xFF19212F) val dark_text_field_background = Color(0xFF273346) val dark_text_field_background_variant = Color(0xFF273346) val dark_text_field_border = Color(0xFF4E5A70) val dark_text_field_text = Color.White val dark_text_field_hint = Color(0xFF79889F) -val dark_button_background = Color(0xFF5478F9) -val dark_button_secondary_background = Color(0xFF79889F) -val dark_button_text = Color.White +val dark_text_hyper_link = Color(0xFF5478F9) + +val dark_primary_button_background = Color(0xFF5478F9) +val dark_primary_button_text = Color.White +val dark_primary_button_border = Color(0xFF4E5A70) +val dark_primary_button_bordered_text = Color(0xFF5478F9) + +val dark_secondary_button_background = dark_primary_button_background +val dark_secondary_button_text = dark_primary_button_text +val dark_secondary_button_border = dark_primary_button_border +val dark_secondary_button_bordered_background = Color(0xFF19212F) +val dark_secondary_button_bordered_text = dark_primary_button_bordered_text + val dark_card_view_background = Color(0xFF273346) val dark_card_view_border = Color(0xFF4E5A70) val dark_divider = Color(0xFF4E5A70) val dark_certificate_foreground = Color(0xD92EB865) val dark_bottom_sheet_toggle = Color(0xFF4E5A70) val dark_warning = Color(0xFFFFC248) +val dark_onWarning = Color.White val dark_info = Color(0xFF0095FF) +val dark_info_variant = Color(0xFF5478F9) +val dark_onInfo = Color.White val dark_rate_stars = Color(0xFFFFC94D) val dark_inactive_button_background = Color(0xFFCCD4E0) val dark_inactive_button_text = Color(0xFF3D4964) @@ -102,6 +131,7 @@ val dark_dates_section_bar_today = dark_info val dark_dates_section_bar_this_week = dark_text_primary_variant val dark_dates_section_bar_next_week = dark_text_field_border val dark_dates_section_bar_upcoming = Color(0xFFCCD4E0) +val dark_auth_google_button_background = Color(0xFF19212F) val dark_auth_facebook_button_background = Color(0xFF0866FF) val dark_auth_microsoft_button_background = Color(0xFA000000) val dark_component_horizontal_progress_completed_and_selected = Color(0xFF30a171) diff --git a/course/src/main/java/org/openedx/course/presentation/ChapterEndFragmentDialog.kt b/course/src/main/java/org/openedx/course/presentation/ChapterEndFragmentDialog.kt index e84766780..c416aa497 100644 --- a/course/src/main/java/org/openedx/course/presentation/ChapterEndFragmentDialog.kt +++ b/course/src/main/java/org/openedx/course/presentation/ChapterEndFragmentDialog.kt @@ -209,7 +209,7 @@ private fun ChapterEndDialogScreen( TextIcon( text = stringResource(id = R.string.course_next_section), painter = painterResource(org.openedx.core.R.drawable.core_ic_forward), - color = MaterialTheme.appColors.buttonText, + color = MaterialTheme.appColors.primaryButtonText, textStyle = MaterialTheme.appTypography.labelLarge, iconModifier = Modifier.rotate(if (isVerticalNavigation) 90f else 0f) ) @@ -219,15 +219,15 @@ private fun ChapterEndDialogScreen( Spacer(Modifier.height(16.dp)) } OpenEdXOutlinedButton( - borderColor = MaterialTheme.appColors.buttonBackground, - textColor = MaterialTheme.appColors.buttonBackground, + borderColor = MaterialTheme.appColors.primaryButtonBackground, + textColor = MaterialTheme.appColors.primaryButtonBackground, text = stringResource(id = R.string.course_back_to_outline), onClick = onBackButtonClick, content = { AutoSizeText( text = stringResource(id = R.string.course_back_to_outline), style = MaterialTheme.appTypography.bodyMedium, - color = MaterialTheme.appColors.buttonBackground + color = MaterialTheme.appColors.primaryButtonBorderedText ) } ) @@ -326,7 +326,7 @@ private fun ChapterEndDialogScreenLandscape( TextIcon( text = stringResource(id = R.string.course_next_section), painter = painterResource(org.openedx.core.R.drawable.core_ic_forward), - color = MaterialTheme.appColors.buttonText, + color = MaterialTheme.appColors.primaryButtonText, textStyle = MaterialTheme.appTypography.labelLarge ) }, @@ -335,15 +335,15 @@ private fun ChapterEndDialogScreenLandscape( Spacer(Modifier.height(16.dp)) } OpenEdXOutlinedButton( - borderColor = MaterialTheme.appColors.buttonBackground, - textColor = MaterialTheme.appColors.buttonBackground, + borderColor = MaterialTheme.appColors.primaryButtonBackground, + textColor = MaterialTheme.appColors.primaryButtonBackground, text = stringResource(id = R.string.course_back_to_outline), onClick = onBackButtonClick, content = { AutoSizeText( text = stringResource(id = R.string.course_back_to_outline), style = MaterialTheme.appTypography.bodyMedium, - color = MaterialTheme.appColors.buttonBackground + color = MaterialTheme.appColors.primaryButtonBorderedText ) } ) diff --git a/course/src/main/java/org/openedx/course/presentation/outline/CourseOutlineScreen.kt b/course/src/main/java/org/openedx/course/presentation/outline/CourseOutlineScreen.kt index d2e47a9cc..c050ff447 100644 --- a/course/src/main/java/org/openedx/course/presentation/outline/CourseOutlineScreen.kt +++ b/course/src/main/java/org/openedx/course/presentation/outline/CourseOutlineScreen.kt @@ -396,7 +396,7 @@ private fun ResumeCourse( TextIcon( text = stringResource(id = R.string.course_resume), painter = painterResource(id = CoreR.drawable.core_ic_forward), - color = MaterialTheme.appColors.buttonText, + color = MaterialTheme.appColors.primaryButtonText, textStyle = MaterialTheme.appTypography.labelLarge ) } @@ -455,7 +455,7 @@ private fun ResumeCourseTablet( TextIcon( text = stringResource(id = R.string.course_resume), painter = painterResource(id = CoreR.drawable.core_ic_forward), - color = MaterialTheme.appColors.buttonText, + color = MaterialTheme.appColors.primaryButtonText, textStyle = MaterialTheme.appTypography.labelLarge ) } 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 02e71df33..a3d38e0e3 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 @@ -348,7 +348,7 @@ fun NavigationUnitsButtons( colors = ButtonDefaults.outlinedButtonColors( backgroundColor = MaterialTheme.appColors.background ), - border = BorderStroke(1.dp, MaterialTheme.appColors.primary), + border = BorderStroke(1.dp, MaterialTheme.appColors.primaryButtonBorder), elevation = null, shape = MaterialTheme.appShapes.navigationButtonShape, onClick = onPrevClick, @@ -377,7 +377,7 @@ fun NavigationUnitsButtons( modifier = Modifier .height(42.dp), colors = ButtonDefaults.buttonColors( - backgroundColor = MaterialTheme.appColors.buttonBackground + backgroundColor = MaterialTheme.appColors.primaryButtonBackground ), elevation = null, shape = MaterialTheme.appShapes.navigationButtonShape, @@ -389,7 +389,7 @@ fun NavigationUnitsButtons( ) { Text( text = nextButtonText, - color = MaterialTheme.appColors.buttonText, + color = MaterialTheme.appColors.primaryButtonText, style = MaterialTheme.appTypography.labelLarge ) Spacer(Modifier.width(8.dp)) @@ -397,7 +397,7 @@ fun NavigationUnitsButtons( modifier = Modifier.rotate(if (isVerticalNavigation || !hasNextBlock) 0f else -90f), painter = nextButtonIcon, contentDescription = null, - tint = MaterialTheme.appColors.buttonText + tint = MaterialTheme.appColors.primaryButtonText ) } } @@ -525,7 +525,11 @@ fun VideoSubtitles( val scaffoldState = rememberScaffoldState() val subtitles = timedTextObject.captions.values.toList() Scaffold(scaffoldState = scaffoldState) { - Column(Modifier.padding(it)) { + Column( + modifier = Modifier + .padding(it) + .background(color = MaterialTheme.appColors.background) + ) { Row( modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically, diff --git a/course/src/main/java/org/openedx/course/presentation/ui/CourseVideosUI.kt b/course/src/main/java/org/openedx/course/presentation/ui/CourseVideosUI.kt index 8e638785a..ad46a6928 100644 --- a/course/src/main/java/org/openedx/course/presentation/ui/CourseVideosUI.kt +++ b/course/src/main/java/org/openedx/course/presentation/ui/CourseVideosUI.kt @@ -568,6 +568,7 @@ private fun AllVideosDownloadItem( } }, colors = SwitchDefaults.colors( + uncheckedThumbColor = MaterialTheme.appColors.primary, checkedThumbColor = MaterialTheme.appColors.primary, checkedTrackColor = MaterialTheme.appColors.primary ) diff --git a/course/src/main/java/org/openedx/course/presentation/unit/NotSupportedUnitFragment.kt b/course/src/main/java/org/openedx/course/presentation/unit/NotSupportedUnitFragment.kt index bdf5dcd8b..0aaae4a3c 100644 --- a/course/src/main/java/org/openedx/course/presentation/unit/NotSupportedUnitFragment.kt +++ b/course/src/main/java/org/openedx/course/presentation/unit/NotSupportedUnitFragment.kt @@ -139,14 +139,14 @@ private fun NotSupportedUnitScreen( .height(42.dp), shape = MaterialTheme.appShapes.buttonShape, colors = ButtonDefaults.buttonColors( - backgroundColor = MaterialTheme.appColors.buttonBackground + backgroundColor = MaterialTheme.appColors.primaryButtonBackground ), onClick = { uriHandler.openUri(uri) }) { Text( text = stringResource(id = courseR.string.course_open_in_browser), - color = MaterialTheme.appColors.buttonText, + color = MaterialTheme.appColors.primaryButtonText, style = MaterialTheme.appTypography.labelLarge ) } diff --git a/course/src/main/java/org/openedx/course/presentation/unit/html/HtmlUnitFragment.kt b/course/src/main/java/org/openedx/course/presentation/unit/html/HtmlUnitFragment.kt index 3a49e0e4b..392fa07fa 100644 --- a/course/src/main/java/org/openedx/course/presentation/unit/html/HtmlUnitFragment.kt +++ b/course/src/main/java/org/openedx/course/presentation/unit/html/HtmlUnitFragment.kt @@ -49,6 +49,7 @@ import androidx.core.os.bundleOf import androidx.fragment.app.Fragment import kotlinx.coroutines.launch import org.koin.androidx.viewmodel.ext.android.viewModel +import org.openedx.core.extension.applyDarkModeIfEnabled import org.openedx.core.extension.isEmailValid import org.openedx.core.extension.loadUrl import org.openedx.core.system.AppCookieManager @@ -212,6 +213,8 @@ private fun HTMLContentView( ) } + val isDarkTheme = isSystemInDarkTheme() + AndroidView( modifier = Modifier .then(screenWidth) @@ -287,11 +290,13 @@ private fun HTMLContentView( setSupportZoom(true) loadsImagesAutomatically = true domStorageEnabled = true + } isVerticalScrollBarEnabled = false isHorizontalScrollBarEnabled = false loadUrl(url, coroutineScope, cookieManager) + applyDarkModeIfEnabled(isDarkTheme) } }, update = { webView -> diff --git a/discovery/src/main/java/org/openedx/discovery/presentation/catalog/CatalogWebView.kt b/discovery/src/main/java/org/openedx/discovery/presentation/catalog/CatalogWebView.kt index 42531f8a0..373516b0a 100644 --- a/discovery/src/main/java/org/openedx/discovery/presentation/catalog/CatalogWebView.kt +++ b/discovery/src/main/java/org/openedx/discovery/presentation/catalog/CatalogWebView.kt @@ -3,9 +3,11 @@ package org.openedx.discovery.presentation.catalog import android.annotation.SuppressLint import android.webkit.WebResourceRequest import android.webkit.WebView +import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.runtime.Composable import androidx.compose.runtime.remember import androidx.compose.ui.platform.LocalContext +import org.openedx.core.extension.applyDarkModeIfEnabled import org.openedx.discovery.presentation.catalog.WebViewLink.Authority as linkAuthority @SuppressLint("SetJavaScriptEnabled", "ComposableNaming") @@ -20,7 +22,7 @@ fun CatalogWebViewScreen( onUriClick: (String, linkAuthority) -> Unit, ): WebView { val context = LocalContext.current - + val isDarkTheme = isSystemInDarkTheme() return remember { WebView(context).apply { webViewClient = object : DefaultWebViewClient( @@ -93,6 +95,7 @@ fun CatalogWebViewScreen( isHorizontalScrollBarEnabled = false loadUrl(url) + applyDarkModeIfEnabled(isDarkTheme) } } } diff --git a/discovery/src/main/java/org/openedx/discovery/presentation/detail/CourseDetailsFragment.kt b/discovery/src/main/java/org/openedx/discovery/presentation/detail/CourseDetailsFragment.kt index 813994307..0060199da 100644 --- a/discovery/src/main/java/org/openedx/discovery/presentation/detail/CourseDetailsFragment.kt +++ b/discovery/src/main/java/org/openedx/discovery/presentation/detail/CourseDetailsFragment.kt @@ -14,6 +14,7 @@ import android.webkit.WebResourceRequest import android.webkit.WebView import android.webkit.WebViewClient import androidx.compose.foundation.background +import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -80,6 +81,7 @@ import org.koin.androidx.viewmodel.ext.android.viewModel import org.koin.core.parameter.parametersOf import org.openedx.core.UIMessage import org.openedx.core.domain.model.Media +import org.openedx.core.extension.applyDarkModeIfEnabled import org.openedx.core.extension.isEmailValid import org.openedx.core.ui.AuthButtonsPanel import org.openedx.core.ui.HandleUIMessage @@ -625,6 +627,7 @@ private fun CourseDescription( onWebPageLoaded: () -> Unit ) { val context = LocalContext.current + val isDarkTheme = isSystemInDarkTheme() AndroidView(modifier = Modifier.then(modifier), factory = { WebView(context).apply { webViewClient = object : WebViewClient() { @@ -674,6 +677,7 @@ private fun CourseDescription( StandardCharsets.UTF_8.name(), null ) + applyDarkModeIfEnabled(isDarkTheme) } }) } diff --git a/discussion/src/main/java/org/openedx/discussion/presentation/threads/DiscussionThreadsFragment.kt b/discussion/src/main/java/org/openedx/discussion/presentation/threads/DiscussionThreadsFragment.kt index 99bf4f26e..34a08ebb2 100644 --- a/discussion/src/main/java/org/openedx/discussion/presentation/threads/DiscussionThreadsFragment.kt +++ b/discussion/src/main/java/org/openedx/discussion/presentation/threads/DiscussionThreadsFragment.kt @@ -393,7 +393,7 @@ private fun DiscussionThreadsScreen( text = filterType.first, painter = painterResource(id = discussionR.drawable.discussion_ic_filter), textStyle = MaterialTheme.appTypography.labelMedium, - color = MaterialTheme.appColors.textAccent, + color = MaterialTheme.appColors.textPrimary, onClick = { currentSelectedList = FilterType.type expandedList = listOf( @@ -423,7 +423,7 @@ private fun DiscussionThreadsScreen( text = sortType.first, painter = painterResource(id = discussionR.drawable.discussion_ic_sort), textStyle = MaterialTheme.appTypography.labelMedium, - color = MaterialTheme.appColors.textAccent, + color = MaterialTheme.appColors.textPrimary, onClick = { currentSelectedList = SortType.type expandedList = listOf( @@ -475,7 +475,7 @@ private fun DiscussionThreadsScreen( Modifier .size(40.dp) .clip(CircleShape) - .background(MaterialTheme.appColors.primary) + .background(MaterialTheme.appColors.secondaryButtonBackground) .clickable { onCreatePostClick() }, @@ -485,7 +485,7 @@ private fun DiscussionThreadsScreen( modifier = Modifier.size(16.dp), painter = painterResource(id = discussionR.drawable.discussion_ic_add_comment), contentDescription = stringResource(id = discussionR.string.discussion_add_comment), - tint = MaterialTheme.appColors.buttonText + tint = MaterialTheme.appColors.primaryButtonText ) } } diff --git a/discussion/src/main/java/org/openedx/discussion/presentation/ui/DiscussionUI.kt b/discussion/src/main/java/org/openedx/discussion/presentation/ui/DiscussionUI.kt index 7d2242850..cd87e0498 100644 --- a/discussion/src/main/java/org/openedx/discussion/presentation/ui/DiscussionUI.kt +++ b/discussion/src/main/java/org/openedx/discussion/presentation/ui/DiscussionUI.kt @@ -587,11 +587,10 @@ fun ThreadItem( thread.commentCount - 1 ), painter = painterResource(id = R.drawable.discussion_ic_responses), - color = MaterialTheme.appColors.textAccent, + color = MaterialTheme.appColors.textPrimary, textStyle = MaterialTheme.appTypography.labelLarge ) } - } diff --git a/profile/src/main/java/org/openedx/profile/presentation/edit/EditProfileFragment.kt b/profile/src/main/java/org/openedx/profile/presentation/edit/EditProfileFragment.kt index 907b3942a..5fc9e9a78 100644 --- a/profile/src/main/java/org/openedx/profile/presentation/edit/EditProfileFragment.kt +++ b/profile/src/main/java/org/openedx/profile/presentation/edit/EditProfileFragment.kt @@ -648,7 +648,7 @@ private fun EditProfileScreen( }, painter = painterResource(id = R.drawable.profile_ic_edit_image), contentDescription = null, - tint = Color.White + tint = MaterialTheme.appColors.onPrimary ) } Spacer(modifier = Modifier.height(20.dp)) @@ -949,10 +949,12 @@ private fun SelectableField( ) } else { TextFieldDefaults.outlinedTextFieldColors( + textColor = MaterialTheme.appColors.textFieldText, + backgroundColor = MaterialTheme.appColors.textFieldBackground, unfocusedBorderColor = MaterialTheme.appColors.textFieldBorder, + cursorColor = MaterialTheme.appColors.textFieldText, disabledBorderColor = MaterialTheme.appColors.textFieldBorder, - disabledTextColor = MaterialTheme.appColors.textPrimary, - backgroundColor = MaterialTheme.appColors.textFieldBackground, + disabledTextColor = MaterialTheme.appColors.textFieldHint, disabledPlaceholderColor = MaterialTheme.appColors.textFieldHint ) } @@ -991,7 +993,7 @@ private fun SelectableField( Text( modifier = Modifier.testTag("txt_placeholder_${name.tagId()}"), text = name, - color = MaterialTheme.appColors.textFieldHint, + color = MaterialTheme.appColors.textFieldText, style = MaterialTheme.appTypography.bodyMedium ) } @@ -1029,8 +1031,10 @@ private fun InputEditField( onValueChanged(it) }, colors = TextFieldDefaults.outlinedTextFieldColors( + textColor = MaterialTheme.appColors.textFieldText, + backgroundColor = MaterialTheme.appColors.textFieldBackground, unfocusedBorderColor = MaterialTheme.appColors.textFieldBorder, - backgroundColor = MaterialTheme.appColors.textFieldBackground + cursorColor = MaterialTheme.appColors.textFieldText, ), shape = MaterialTheme.appShapes.textFieldShape, placeholder = { @@ -1116,14 +1120,14 @@ private fun LeaveProfile( OpenEdXButton( text = stringResource(id = R.string.profile_leave), onClick = onLeaveClick, - backgroundColor = MaterialTheme.appColors.warning, + backgroundColor = MaterialTheme.appColors.primary, content = { Text( modifier = Modifier .testTag("txt_leave") .fillMaxWidth(), text = stringResource(id = R.string.profile_leave), - color = MaterialTheme.appColors.textWarning, + color = MaterialTheme.appColors.primaryButtonText, style = MaterialTheme.appTypography.labelLarge, textAlign = TextAlign.Center ) @@ -1131,7 +1135,7 @@ private fun LeaveProfile( ) Spacer(Modifier.height(24.dp)) OpenEdXOutlinedButton( - borderColor = MaterialTheme.appColors.textPrimary, + borderColor = MaterialTheme.appColors.textFieldBorder, textColor = MaterialTheme.appColors.textPrimary, text = stringResource(id = R.string.profile_keep_editing), onClick = onDismissRequest @@ -1208,20 +1212,20 @@ private fun LeaveProfileLandscape( ) { OpenEdXButton( text = stringResource(id = R.string.profile_leave), - backgroundColor = MaterialTheme.appColors.warning, + backgroundColor = MaterialTheme.appColors.primary, content = { AutoSizeText( modifier = Modifier.testTag("txt_leave_profile_dialog_leave"), text = stringResource(id = R.string.profile_leave), style = MaterialTheme.appTypography.bodyMedium, - color = MaterialTheme.appColors.textDark + color = MaterialTheme.appColors.primaryButtonText ) }, onClick = onLeaveClick ) Spacer(Modifier.height(16.dp)) OpenEdXOutlinedButton( - borderColor = MaterialTheme.appColors.textPrimary, + borderColor = MaterialTheme.appColors.textFieldBorder, textColor = MaterialTheme.appColors.textPrimary, text = stringResource(id = R.string.profile_keep_editing), onClick = onDismissRequest, diff --git a/profile/src/main/java/org/openedx/profile/presentation/manageaccount/compose/ManageAccountView.kt b/profile/src/main/java/org/openedx/profile/presentation/manageaccount/compose/ManageAccountView.kt index 42ff5afef..970ff2f91 100644 --- a/profile/src/main/java/org/openedx/profile/presentation/manageaccount/compose/ManageAccountView.kt +++ b/profile/src/main/java/org/openedx/profile/presentation/manageaccount/compose/ManageAccountView.kt @@ -174,7 +174,7 @@ internal fun ManageAccountView( onClick = { onAction(ManageAccountViewAction.EditAccountClick) }, - borderColor = MaterialTheme.appColors.buttonBackground, + borderColor = MaterialTheme.appColors.primaryButtonBackground, textColor = MaterialTheme.appColors.textAccent ) Spacer(modifier = Modifier.height(12.dp)) diff --git a/profile/src/main/java/org/openedx/profile/presentation/profile/compose/ProfileView.kt b/profile/src/main/java/org/openedx/profile/presentation/profile/compose/ProfileView.kt index bec24967f..411ac156d 100644 --- a/profile/src/main/java/org/openedx/profile/presentation/profile/compose/ProfileView.kt +++ b/profile/src/main/java/org/openedx/profile/presentation/profile/compose/ProfileView.kt @@ -149,7 +149,7 @@ internal fun ProfileView( onClick = { onAction(ProfileViewAction.EditAccountClick) }, - borderColor = MaterialTheme.appColors.buttonBackground, + borderColor = MaterialTheme.appColors.primaryButtonBackground, textColor = MaterialTheme.appColors.textAccent ) Spacer(modifier = Modifier.height(12.dp)) 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 a76ff9a10..7d97eee40 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 @@ -210,7 +210,7 @@ fun NextFinishButton( .testTag("btn_next") .height(42.dp), colors = ButtonDefaults.buttonColors( - backgroundColor = MaterialTheme.appColors.buttonBackground + backgroundColor = MaterialTheme.appColors.primaryButtonBackground ), elevation = null, shape = MaterialTheme.appShapes.navigationButtonShape, @@ -231,14 +231,14 @@ fun NextFinishButton( Text( modifier = Modifier.testTag("txt_next"), text = stringResource(id = R.string.whats_new_navigation_next), - color = MaterialTheme.appColors.buttonText, + color = MaterialTheme.appColors.primaryButtonText, style = MaterialTheme.appTypography.labelLarge ) Spacer(Modifier.width(8.dp)) Icon( painter = painterResource(id = org.openedx.core.R.drawable.core_ic_forward), contentDescription = null, - tint = MaterialTheme.appColors.buttonText + tint = MaterialTheme.appColors.primaryButtonText ) } } else { @@ -249,14 +249,14 @@ fun NextFinishButton( Text( modifier = Modifier.testTag("txt_done"), text = stringResource(id = R.string.whats_new_navigation_done), - color = MaterialTheme.appColors.buttonText, + color = MaterialTheme.appColors.primaryButtonText, style = MaterialTheme.appTypography.labelLarge ) Spacer(Modifier.width(8.dp)) Icon( painter = painterResource(id = org.openedx.core.R.drawable.core_ic_check), contentDescription = null, - tint = MaterialTheme.appColors.buttonText + tint = MaterialTheme.appColors.primaryButtonText ) } } From d04c0206a4acea2fb16ef92d1ecf8808799f2ea2 Mon Sep 17 00:00:00 2001 From: PavloNetrebchuk <141041606+PavloNetrebchuk@users.noreply.github.com> Date: Wed, 29 May 2024 13:03:12 +0300 Subject: [PATCH 09/18] feat: [FC-0047] Calendar main screen and dialogs (#322) * feat: Created calendar setting screen * feat: CalendarAccessDialog * feat: NewCalendarDialog * fix: Fixes according to PR feedback --- app/src/main/AndroidManifest.xml | 2 + .../main/java/org/openedx/app/AppRouter.kt | 9 +- .../main/java/org/openedx/app/di/AppModule.kt | 2 +- .../java/org/openedx/app/di/ScreenModule.kt | 4 +- .../java/org/openedx/core/config/Config.kt | 5 + .../core/presentation/dialog/DialogUI.kt | 56 +++ .../dialog/appreview/AppReviewUI.kt | 51 +-- .../calendarsync/CalendarSyncDialog.kt | 6 +- .../calendarsync/CalendarSyncDialogType.kt | 44 ++ .../calendarsync/CalendarSyncUIState.kt | 2 +- .../calendarsync/DialogProperties.kt | 2 +- .../{ => video}/VideoQualityFragment.kt | 2 +- .../settings/{ => video}/VideoQualityType.kt | 2 +- .../{ => video}/VideoQualityViewModel.kt | 2 +- .../openedx/core/system}/CalendarManager.kt | 7 +- .../java/org/openedx/core/ui/ComposeCommon.kt | 3 +- .../org/openedx/core/ui/ComposeExtensions.kt | 19 +- core/src/main/res/values/strings.xml | 35 ++ course/src/main/AndroidManifest.xml | 5 - .../course/presentation/CourseRouter.kt | 2 +- .../calendarsync/CalendarSyncDialogType.kt | 45 -- .../container/CourseContainerFragment.kt | 4 +- .../container/CourseContainerViewModel.kt | 15 +- .../presentation/dates/CourseDatesScreen.kt | 6 +- .../dates/CourseDatesViewModel.kt | 6 +- .../outline/CourseOutlineViewModel.kt | 2 +- .../course/presentation/ui/CourseVideosUI.kt | 2 +- course/src/main/res/values/strings.xml | 35 -- .../container/CourseContainerViewModelTest.kt | 2 +- .../dates/CourseDatesViewModelTest.kt | 2 +- .../profile/presentation/ProfileRouter.kt | 4 +- .../calendar/CalendarAccessDialogFragment.kt | 161 ++++++++ .../presentation/calendar/CalendarColor.kt | 19 + .../presentation/calendar/CalendarFragment.kt | 264 ++++++++++++ .../calendar/CalendarViewModel.kt | 14 + .../calendar/NewCalendarDialogFragment.kt | 390 ++++++++++++++++++ .../presentation/settings/SettingsFragment.kt | 11 +- .../presentation/settings/SettingsScreenUI.kt | 66 ++- .../settings/SettingsViewModel.kt | 4 + .../profile/presentation/ui/SettingsUI.kt | 17 +- .../video/VideoSettingsViewModel.kt | 2 +- profile/src/main/res/values/strings.xml | 21 + 42 files changed, 1141 insertions(+), 211 deletions(-) create mode 100644 core/src/main/java/org/openedx/core/presentation/dialog/DialogUI.kt rename {course/src/main/java/org/openedx/course/presentation => core/src/main/java/org/openedx/core/presentation/settings}/calendarsync/CalendarSyncDialog.kt (98%) create mode 100644 core/src/main/java/org/openedx/core/presentation/settings/calendarsync/CalendarSyncDialogType.kt rename {course/src/main/java/org/openedx/course/presentation => core/src/main/java/org/openedx/core/presentation/settings}/calendarsync/CalendarSyncUIState.kt (89%) rename {course/src/main/java/org/openedx/course/presentation => core/src/main/java/org/openedx/core/presentation/settings}/calendarsync/DialogProperties.kt (78%) rename core/src/main/java/org/openedx/core/presentation/settings/{ => video}/VideoQualityFragment.kt (99%) rename core/src/main/java/org/openedx/core/presentation/settings/{ => video}/VideoQualityType.kt (51%) rename core/src/main/java/org/openedx/core/presentation/settings/{ => video}/VideoQualityViewModel.kt (98%) rename {course/src/main/java/org/openedx/course/presentation/calendarsync => core/src/main/java/org/openedx/core/system}/CalendarManager.kt (98%) delete mode 100644 course/src/main/AndroidManifest.xml delete mode 100644 course/src/main/java/org/openedx/course/presentation/calendarsync/CalendarSyncDialogType.kt create mode 100644 profile/src/main/java/org/openedx/profile/presentation/calendar/CalendarAccessDialogFragment.kt create mode 100644 profile/src/main/java/org/openedx/profile/presentation/calendar/CalendarColor.kt create mode 100644 profile/src/main/java/org/openedx/profile/presentation/calendar/CalendarFragment.kt create mode 100644 profile/src/main/java/org/openedx/profile/presentation/calendar/CalendarViewModel.kt create mode 100644 profile/src/main/java/org/openedx/profile/presentation/calendar/NewCalendarDialogFragment.kt diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 8020f6b74..3e8282acb 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -6,6 +6,8 @@ + + diff --git a/app/src/main/java/org/openedx/app/AppRouter.kt b/app/src/main/java/org/openedx/app/AppRouter.kt index 21f3b5aee..a68b550a2 100644 --- a/app/src/main/java/org/openedx/app/AppRouter.kt +++ b/app/src/main/java/org/openedx/app/AppRouter.kt @@ -13,8 +13,8 @@ import org.openedx.core.presentation.course.CourseViewMode import org.openedx.core.presentation.global.app_upgrade.AppUpgradeRouter import org.openedx.core.presentation.global.app_upgrade.UpgradeRequiredFragment import org.openedx.core.presentation.global.webview.WebContentFragment -import org.openedx.core.presentation.settings.VideoQualityFragment -import org.openedx.core.presentation.settings.VideoQualityType +import org.openedx.core.presentation.settings.video.VideoQualityFragment +import org.openedx.core.presentation.settings.video.VideoQualityType import org.openedx.course.presentation.CourseRouter import org.openedx.course.presentation.container.CourseContainerFragment import org.openedx.course.presentation.container.NoAccessCourseContainerFragment @@ -44,6 +44,7 @@ import org.openedx.discussion.presentation.threads.DiscussionThreadsFragment import org.openedx.profile.domain.model.Account import org.openedx.profile.presentation.ProfileRouter import org.openedx.profile.presentation.anothersaccount.AnothersProfileFragment +import org.openedx.profile.presentation.calendar.CalendarFragment import org.openedx.profile.presentation.delete.DeleteProfileFragment import org.openedx.profile.presentation.edit.EditProfileFragment import org.openedx.profile.presentation.manageaccount.ManageAccountFragment @@ -370,6 +371,10 @@ class AppRouter : AuthRouter, DiscoveryRouter, DashboardRouter, CourseRouter, Di override fun navigateToManageAccount(fm: FragmentManager) { replaceFragmentWithBackStack(fm, ManageAccountFragment()) } + + override fun navigateToCalendarSettings(fm: FragmentManager) { + replaceFragmentWithBackStack(fm, CalendarFragment()) + } //endregion private fun replaceFragmentWithBackStack(fm: FragmentManager, fragment: Fragment) { diff --git a/app/src/main/java/org/openedx/app/di/AppModule.kt b/app/src/main/java/org/openedx/app/di/AppModule.kt index 16a30c0c6..529f00ac0 100644 --- a/app/src/main/java/org/openedx/app/di/AppModule.kt +++ b/app/src/main/java/org/openedx/app/di/AppModule.kt @@ -40,6 +40,7 @@ import org.openedx.core.presentation.global.AppData import org.openedx.core.presentation.global.WhatsNewGlobalManager import org.openedx.core.presentation.global.app_upgrade.AppUpgradeRouter import org.openedx.core.system.AppCookieManager +import org.openedx.core.system.CalendarManager import org.openedx.core.system.ResourceManager import org.openedx.core.system.connection.NetworkConnection import org.openedx.core.system.notifier.AppUpgradeNotifier @@ -50,7 +51,6 @@ import org.openedx.core.system.notifier.VideoNotifier import org.openedx.course.data.storage.CoursePreferences import org.openedx.course.presentation.CourseAnalytics import org.openedx.course.presentation.CourseRouter -import org.openedx.course.presentation.calendarsync.CalendarManager import org.openedx.dashboard.presentation.DashboardAnalytics import org.openedx.dashboard.presentation.DashboardRouter import org.openedx.discovery.presentation.DiscoveryAnalytics 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 c9c395a01..3c99dbc0f 100644 --- a/app/src/main/java/org/openedx/app/di/ScreenModule.kt +++ b/app/src/main/java/org/openedx/app/di/ScreenModule.kt @@ -13,7 +13,7 @@ import org.openedx.auth.presentation.signin.SignInViewModel import org.openedx.auth.presentation.signup.SignUpViewModel import org.openedx.core.Validator import org.openedx.core.presentation.dialog.selectorbottomsheet.SelectDialogViewModel -import org.openedx.core.presentation.settings.VideoQualityViewModel +import org.openedx.core.presentation.settings.video.VideoQualityViewModel import org.openedx.course.data.repository.CourseRepository import org.openedx.course.domain.interactor.CourseInteractor import org.openedx.course.presentation.container.CourseContainerViewModel @@ -53,6 +53,7 @@ import org.openedx.profile.data.repository.ProfileRepository import org.openedx.profile.domain.interactor.ProfileInteractor import org.openedx.profile.domain.model.Account import org.openedx.profile.presentation.anothersaccount.AnothersProfileViewModel +import org.openedx.profile.presentation.calendar.CalendarViewModel import org.openedx.profile.presentation.delete.DeleteProfileViewModel import org.openedx.profile.presentation.edit.EditProfileViewModel import org.openedx.profile.presentation.manageaccount.ManageAccountViewModel @@ -163,6 +164,7 @@ val screenModule = module { ) } viewModel { ManageAccountViewModel(get(), get(), get(), get(), get()) } + viewModel { CalendarViewModel(get()) } single { CourseRepository(get(), get(), get(), get(), get()) } factory { CourseInteractor(get()) } diff --git a/core/src/main/java/org/openedx/core/config/Config.kt b/core/src/main/java/org/openedx/core/config/Config.kt index b20ea9f0a..4d71b8dc1 100644 --- a/core/src/main/java/org/openedx/core/config/Config.kt +++ b/core/src/main/java/org/openedx/core/config/Config.kt @@ -23,6 +23,10 @@ class Config(context: Context) { } } + fun getAppId(): String { + return getString(APPLICATION_ID, "") + } + fun getApiHostURL(): String { return getString(API_HOST_URL, "") } @@ -146,6 +150,7 @@ class Config(context: Context) { } companion object { + private const val APPLICATION_ID = "APPLICATION_ID" private const val API_HOST_URL = "API_HOST_URL" private const val URI_SCHEME = "URI_SCHEME" private const val OAUTH_CLIENT_ID = "OAUTH_CLIENT_ID" diff --git a/core/src/main/java/org/openedx/core/presentation/dialog/DialogUI.kt b/core/src/main/java/org/openedx/core/presentation/dialog/DialogUI.kt new file mode 100644 index 000000000..17b1d2874 --- /dev/null +++ b/core/src/main/java/org/openedx/core/presentation/dialog/DialogUI.kt @@ -0,0 +1,56 @@ +package org.openedx.core.presentation.dialog + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxScope +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.widthIn +import androidx.compose.material.MaterialTheme +import androidx.compose.material.Surface +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.unit.dp +import org.openedx.core.ui.noRippleClickable +import org.openedx.core.ui.theme.appColors +import org.openedx.core.ui.theme.appShapes + +@Composable +fun DefaultDialogBox( + modifier: Modifier = Modifier, + onDismissClick: () -> Unit, + content: @Composable (BoxScope.() -> Unit) +) { + Surface( + modifier = modifier, + color = Color.Transparent + ) { + Box( + modifier = Modifier + .fillMaxSize() + .padding(horizontal = 4.dp) + .noRippleClickable { + onDismissClick() + }, + contentAlignment = Alignment.Center + ) { + Box( + modifier = Modifier + .widthIn(max = 640.dp) + .fillMaxWidth() + .clip(MaterialTheme.appShapes.cardShape) + .noRippleClickable {} + .background( + color = MaterialTheme.appColors.background, + shape = MaterialTheme.appShapes.cardShape + ) + ) { + content.invoke(this) + } + } + } +} diff --git a/core/src/main/java/org/openedx/core/presentation/dialog/appreview/AppReviewUI.kt b/core/src/main/java/org/openedx/core/presentation/dialog/appreview/AppReviewUI.kt index b924cd543..a1df55a05 100644 --- a/core/src/main/java/org/openedx/core/presentation/dialog/appreview/AppReviewUI.kt +++ b/core/src/main/java/org/openedx/core/presentation/dialog/appreview/AppReviewUI.kt @@ -4,26 +4,20 @@ import android.content.res.Configuration.ORIENTATION_LANDSCAPE import android.content.res.Configuration.UI_MODE_NIGHT_NO import android.content.res.Configuration.UI_MODE_NIGHT_YES import androidx.compose.foundation.Image -import androidx.compose.foundation.background import androidx.compose.foundation.gestures.detectTapGestures import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.BoxScope import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.widthIn import androidx.compose.material.Button import androidx.compose.material.ButtonDefaults import androidx.compose.material.Icon import androidx.compose.material.MaterialTheme import androidx.compose.material.OutlinedTextField -import androidx.compose.material.Surface import androidx.compose.material.Text import androidx.compose.material.TextFieldDefaults import androidx.compose.material.icons.Icons @@ -40,7 +34,6 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.layout.ContentScale @@ -54,7 +47,7 @@ import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import org.openedx.core.R -import org.openedx.core.ui.noRippleClickable +import org.openedx.core.presentation.dialog.DefaultDialogBox import org.openedx.core.ui.theme.OpenEdXTheme import org.openedx.core.ui.theme.appColors import org.openedx.core.ui.theme.appShapes @@ -78,7 +71,7 @@ fun ThankYouDialog( DefaultDialogBox( modifier = modifier, - onDismissClock = onNotNowClick + onDismissClick = onNotNowClick ) { Column( modifier = Modifier @@ -139,7 +132,7 @@ fun FeedbackDialog( DefaultDialogBox( modifier = modifier, - onDismissClock = onNotNowClick + onDismissClick = onNotNowClick ) { Column( modifier = Modifier @@ -210,7 +203,7 @@ fun RateDialog( ) { DefaultDialogBox( modifier = modifier, - onDismissClock = onNotNowClick + onDismissClick = onNotNowClick ) { Column( modifier = Modifier @@ -252,42 +245,6 @@ fun RateDialog( } } -@Composable -fun DefaultDialogBox( - modifier: Modifier = Modifier, - onDismissClock: () -> Unit, - content: @Composable (BoxScope.() -> Unit) -) { - Surface( - modifier = modifier, - color = Color.Transparent - ) { - Box( - modifier = Modifier - .fillMaxSize() - .padding(horizontal = 4.dp) - .noRippleClickable { - onDismissClock() - }, - contentAlignment = Alignment.Center - ) { - Box( - modifier = Modifier - .widthIn(max = 640.dp) - .fillMaxWidth() - .clip(MaterialTheme.appShapes.cardShape) - .noRippleClickable {} - .background( - color = MaterialTheme.appColors.background, - shape = MaterialTheme.appShapes.cardShape - ) - ) { - content.invoke(this) - } - } - } -} - @Composable fun TransparentTextButton( text: String, diff --git a/course/src/main/java/org/openedx/course/presentation/calendarsync/CalendarSyncDialog.kt b/core/src/main/java/org/openedx/core/presentation/settings/calendarsync/CalendarSyncDialog.kt similarity index 98% rename from course/src/main/java/org/openedx/course/presentation/calendarsync/CalendarSyncDialog.kt rename to core/src/main/java/org/openedx/core/presentation/settings/calendarsync/CalendarSyncDialog.kt index a3775c99b..ac358228e 100644 --- a/course/src/main/java/org/openedx/course/presentation/calendarsync/CalendarSyncDialog.kt +++ b/core/src/main/java/org/openedx/core/presentation/settings/calendarsync/CalendarSyncDialog.kt @@ -1,4 +1,4 @@ -package org.openedx.course.presentation.calendarsync +package org.openedx.core.presentation.settings.calendarsync import android.content.res.Configuration import androidx.compose.foundation.background @@ -23,13 +23,13 @@ import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.tooling.preview.PreviewParameterProvider import androidx.compose.ui.unit.dp import androidx.compose.ui.window.Dialog +import org.openedx.core.R import org.openedx.core.extension.takeIfNotEmpty import org.openedx.core.presentation.global.app_upgrade.TransparentTextButton 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 androidx.compose.ui.window.DialogProperties as AlertDialogProperties import org.openedx.core.R as CoreR @@ -192,7 +192,7 @@ private fun SyncDialog() { verticalArrangement = Arrangement.spacedBy(8.dp), ) { Text( - text = stringResource(id = R.string.course_title_syncing_calendar), + text = stringResource(id = R.string.core_title_syncing_calendar), color = MaterialTheme.appColors.textPrimary, style = MaterialTheme.appTypography.titleMedium, maxLines = 2, diff --git a/core/src/main/java/org/openedx/core/presentation/settings/calendarsync/CalendarSyncDialogType.kt b/core/src/main/java/org/openedx/core/presentation/settings/calendarsync/CalendarSyncDialogType.kt new file mode 100644 index 000000000..daab61fa5 --- /dev/null +++ b/core/src/main/java/org/openedx/core/presentation/settings/calendarsync/CalendarSyncDialogType.kt @@ -0,0 +1,44 @@ +package org.openedx.core.presentation.settings.calendarsync + +import org.openedx.core.R + +enum class CalendarSyncDialogType( + val titleResId: Int = 0, + val messageResId: Int = 0, + val positiveButtonResId: Int = 0, + val negativeButtonResId: Int = 0, +) { + SYNC_DIALOG( + titleResId = R.string.core_title_add_course_calendar, + messageResId = R.string.core_message_add_course_calendar, + positiveButtonResId = R.string.core_ok, + negativeButtonResId = R.string.core_cancel + ), + UN_SYNC_DIALOG( + titleResId = R.string.core_title_remove_course_calendar, + messageResId = R.string.core_message_remove_course_calendar, + positiveButtonResId = R.string.core_label_remove, + negativeButtonResId = R.string.core_cancel + ), + PERMISSION_DIALOG( + titleResId = R.string.core_title_request_calendar_permission, + messageResId = R.string.core_message_request_calendar_permission, + positiveButtonResId = R.string.core_ok, + negativeButtonResId = R.string.core_label_do_not_allow + ), + EVENTS_DIALOG( + messageResId = R.string.core_message_course_calendar_added, + positiveButtonResId = R.string.core_label_view_events, + negativeButtonResId = R.string.core_label_done + ), + OUT_OF_SYNC_DIALOG( + titleResId = R.string.core_title_calendar_out_of_date, + messageResId = R.string.core_message_calendar_out_of_date, + positiveButtonResId = R.string.core_label_update_now, + negativeButtonResId = R.string.core_label_remove_course_calendar, + ), + LOADING_DIALOG( + titleResId = R.string.core_title_syncing_calendar + ), + NONE; +} diff --git a/course/src/main/java/org/openedx/course/presentation/calendarsync/CalendarSyncUIState.kt b/core/src/main/java/org/openedx/core/presentation/settings/calendarsync/CalendarSyncUIState.kt similarity index 89% rename from course/src/main/java/org/openedx/course/presentation/calendarsync/CalendarSyncUIState.kt rename to core/src/main/java/org/openedx/core/presentation/settings/calendarsync/CalendarSyncUIState.kt index 24d2212e2..e3062d970 100644 --- a/course/src/main/java/org/openedx/course/presentation/calendarsync/CalendarSyncUIState.kt +++ b/core/src/main/java/org/openedx/core/presentation/settings/calendarsync/CalendarSyncUIState.kt @@ -1,4 +1,4 @@ -package org.openedx.course.presentation.calendarsync +package org.openedx.core.presentation.settings.calendarsync import org.openedx.core.domain.model.CourseDateBlock import java.util.concurrent.atomic.AtomicReference diff --git a/course/src/main/java/org/openedx/course/presentation/calendarsync/DialogProperties.kt b/core/src/main/java/org/openedx/core/presentation/settings/calendarsync/DialogProperties.kt similarity index 78% rename from course/src/main/java/org/openedx/course/presentation/calendarsync/DialogProperties.kt rename to core/src/main/java/org/openedx/core/presentation/settings/calendarsync/DialogProperties.kt index cefded76c..cfca43193 100644 --- a/course/src/main/java/org/openedx/course/presentation/calendarsync/DialogProperties.kt +++ b/core/src/main/java/org/openedx/core/presentation/settings/calendarsync/DialogProperties.kt @@ -1,4 +1,4 @@ -package org.openedx.course.presentation.calendarsync +package org.openedx.core.presentation.settings.calendarsync data class DialogProperties( val title: String, diff --git a/core/src/main/java/org/openedx/core/presentation/settings/VideoQualityFragment.kt b/core/src/main/java/org/openedx/core/presentation/settings/video/VideoQualityFragment.kt similarity index 99% rename from core/src/main/java/org/openedx/core/presentation/settings/VideoQualityFragment.kt rename to core/src/main/java/org/openedx/core/presentation/settings/video/VideoQualityFragment.kt index e26d882eb..edd00ce53 100644 --- a/core/src/main/java/org/openedx/core/presentation/settings/VideoQualityFragment.kt +++ b/core/src/main/java/org/openedx/core/presentation/settings/video/VideoQualityFragment.kt @@ -1,4 +1,4 @@ -package org.openedx.core.presentation.settings +package org.openedx.core.presentation.settings.video import android.content.res.Configuration import android.os.Bundle diff --git a/core/src/main/java/org/openedx/core/presentation/settings/VideoQualityType.kt b/core/src/main/java/org/openedx/core/presentation/settings/video/VideoQualityType.kt similarity index 51% rename from core/src/main/java/org/openedx/core/presentation/settings/VideoQualityType.kt rename to core/src/main/java/org/openedx/core/presentation/settings/video/VideoQualityType.kt index 4c7973d6a..c39b6d220 100644 --- a/core/src/main/java/org/openedx/core/presentation/settings/VideoQualityType.kt +++ b/core/src/main/java/org/openedx/core/presentation/settings/video/VideoQualityType.kt @@ -1,4 +1,4 @@ -package org.openedx.core.presentation.settings +package org.openedx.core.presentation.settings.video enum class VideoQualityType { Streaming, Download diff --git a/core/src/main/java/org/openedx/core/presentation/settings/VideoQualityViewModel.kt b/core/src/main/java/org/openedx/core/presentation/settings/video/VideoQualityViewModel.kt similarity index 98% rename from core/src/main/java/org/openedx/core/presentation/settings/VideoQualityViewModel.kt rename to core/src/main/java/org/openedx/core/presentation/settings/video/VideoQualityViewModel.kt index c6d5176ea..bf30bbe30 100644 --- a/core/src/main/java/org/openedx/core/presentation/settings/VideoQualityViewModel.kt +++ b/core/src/main/java/org/openedx/core/presentation/settings/video/VideoQualityViewModel.kt @@ -1,4 +1,4 @@ -package org.openedx.core.presentation.settings +package org.openedx.core.presentation.settings.video import androidx.lifecycle.LiveData import androidx.lifecycle.MutableLiveData diff --git a/course/src/main/java/org/openedx/course/presentation/calendarsync/CalendarManager.kt b/core/src/main/java/org/openedx/core/system/CalendarManager.kt similarity index 98% rename from course/src/main/java/org/openedx/course/presentation/calendarsync/CalendarManager.kt rename to core/src/main/java/org/openedx/core/system/CalendarManager.kt index 54639e922..53d7a1e1f 100644 --- a/course/src/main/java/org/openedx/course/presentation/calendarsync/CalendarManager.kt +++ b/core/src/main/java/org/openedx/core/system/CalendarManager.kt @@ -1,4 +1,4 @@ -package org.openedx.course.presentation.calendarsync +package org.openedx.core.system import android.annotation.SuppressLint import android.content.ContentUris @@ -10,12 +10,11 @@ import android.database.Cursor import android.net.Uri import android.provider.CalendarContract import androidx.core.content.ContextCompat +import org.openedx.core.R import org.openedx.core.data.storage.CorePreferences import org.openedx.core.domain.model.CourseDateBlock -import org.openedx.core.system.ResourceManager import org.openedx.core.utils.Logger import org.openedx.core.utils.toCalendar -import org.openedx.course.R import java.util.Calendar import java.util.TimeZone import java.util.concurrent.TimeUnit @@ -165,7 +164,7 @@ class CalendarManager( put(CalendarContract.Events.DTEND, endMillis) put( CalendarContract.Events.TITLE, - "${resourceManager.getString(R.string.course_assignment_due_tag)} : $courseName" + "${resourceManager.getString(R.string.core_assignment_due_tag)} : $courseName" ) put( CalendarContract.Events.DESCRIPTION, diff --git a/core/src/main/java/org/openedx/core/ui/ComposeCommon.kt b/core/src/main/java/org/openedx/core/ui/ComposeCommon.kt index 212014177..1692e7a4d 100644 --- a/core/src/main/java/org/openedx/core/ui/ComposeCommon.kt +++ b/core/src/main/java/org/openedx/core/ui/ComposeCommon.kt @@ -939,6 +939,7 @@ fun TextIcon( icon: ImageVector, color: Color, textStyle: TextStyle = MaterialTheme.appTypography.bodySmall, + iconModifier: Modifier = Modifier, onClick: (() -> Unit)? = null, ) { val modifier = if (onClick == null) { @@ -953,7 +954,7 @@ fun TextIcon( ) { Text(text = text, color = color, style = textStyle) Icon( - modifier = Modifier.size((textStyle.fontSize.value + 4).dp), + modifier = iconModifier.size((textStyle.fontSize.value + 4).dp), imageVector = icon, contentDescription = null, tint = color diff --git a/core/src/main/java/org/openedx/core/ui/ComposeExtensions.kt b/core/src/main/java/org/openedx/core/ui/ComposeExtensions.kt index 6cf198f53..1659a0417 100644 --- a/core/src/main/java/org/openedx/core/ui/ComposeExtensions.kt +++ b/core/src/main/java/org/openedx/core/ui/ComposeExtensions.kt @@ -30,6 +30,7 @@ import androidx.compose.ui.graphics.Path import androidx.compose.ui.graphics.PathEffect import androidx.compose.ui.graphics.drawscope.Stroke import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.layout.layout import androidx.compose.ui.platform.LocalConfiguration import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalDensity @@ -37,6 +38,7 @@ import androidx.compose.ui.platform.LocalInspectionMode import androidx.compose.ui.platform.LocalView import androidx.compose.ui.res.painterResource import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.awaitCancellation import kotlinx.coroutines.launch @@ -192,4 +194,19 @@ fun Modifier.settingsHeaderBackground(): Modifier = composed { contentScale = ContentScale.FillWidth, alignment = Alignment.TopCenter ) -} \ No newline at end of file +} + +fun Modifier.crop( + horizontal: Dp = 0.dp, + vertical: Dp = 0.dp, +): Modifier = this.layout { measurable, constraints -> + val placeable = measurable.measure(constraints) + fun Dp.toPxInt(): Int = this.toPx().toInt() + + layout( + placeable.width - (horizontal * 2).toPxInt(), + placeable.height - (vertical * 2).toPxInt() + ) { + placeable.placeRelative(-horizontal.toPx().toInt(), -vertical.toPx().toInt()) + } +} diff --git a/core/src/main/res/values/strings.xml b/core/src/main/res/values/strings.xml index ed4b1d99d..668c61935 100644 --- a/core/src/main/res/values/strings.xml +++ b/core/src/main/res/values/strings.xml @@ -132,6 +132,41 @@ Video download quality Manage Account + Assignment Due + Syncing calendar… + + + Sync to calendar + Automatically sync all deadlines and due dates for this course to your calendar. + + \“%s\” Would Like to Access Your Calendar + %s would like to use your calendar list to subscribe to your personalized %s calendar for this course. + Don’t allow + + Add Course Dates to Calendar + Would you like to add \“%s\” dates to your calendar? \n\nYou can edit or remove your course dates at any time from your calendar or settings. + + \“%s\” has been added to your phone\'s calendar. + View Events + Done + + Remove Course Dates from Calendar + Would you like to remove the \“%s\” dates from your calendar? + Remove + + Your course calendar is out of date + Your course dates have been shifted and your course calendar is no longer up to date with your new schedule. + Update Now + Remove Course Calendar + + Your course calendar has been added. + Your course calendar has been removed. + Your course calendar has been updated. + Error Adding Calendar, Please try later + + + + Home Videos Discussions diff --git a/course/src/main/AndroidManifest.xml b/course/src/main/AndroidManifest.xml deleted file mode 100644 index 5c18ebdbf..000000000 --- a/course/src/main/AndroidManifest.xml +++ /dev/null @@ -1,5 +0,0 @@ - - - - - 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 b2f520679..9b34e7617 100644 --- a/course/src/main/java/org/openedx/course/presentation/CourseRouter.kt +++ b/course/src/main/java/org/openedx/course/presentation/CourseRouter.kt @@ -2,7 +2,7 @@ package org.openedx.course.presentation import androidx.fragment.app.FragmentManager import org.openedx.core.presentation.course.CourseViewMode -import org.openedx.core.presentation.settings.VideoQualityType +import org.openedx.core.presentation.settings.video.VideoQualityType import org.openedx.course.presentation.handouts.HandoutsType interface CourseRouter { diff --git a/course/src/main/java/org/openedx/course/presentation/calendarsync/CalendarSyncDialogType.kt b/course/src/main/java/org/openedx/course/presentation/calendarsync/CalendarSyncDialogType.kt deleted file mode 100644 index 57d6c0dac..000000000 --- a/course/src/main/java/org/openedx/course/presentation/calendarsync/CalendarSyncDialogType.kt +++ /dev/null @@ -1,45 +0,0 @@ -package org.openedx.course.presentation.calendarsync - -import org.openedx.course.R -import org.openedx.core.R as CoreR - -enum class CalendarSyncDialogType( - val titleResId: Int = 0, - val messageResId: Int = 0, - val positiveButtonResId: Int = 0, - val negativeButtonResId: Int = 0, -) { - SYNC_DIALOG( - titleResId = R.string.course_title_add_course_calendar, - messageResId = R.string.course_message_add_course_calendar, - positiveButtonResId = CoreR.string.core_ok, - negativeButtonResId = CoreR.string.core_cancel - ), - UN_SYNC_DIALOG( - titleResId = R.string.course_title_remove_course_calendar, - messageResId = R.string.course_message_remove_course_calendar, - positiveButtonResId = R.string.course_label_remove, - negativeButtonResId = CoreR.string.core_cancel - ), - PERMISSION_DIALOG( - titleResId = R.string.course_title_request_calendar_permission, - messageResId = R.string.course_message_request_calendar_permission, - positiveButtonResId = CoreR.string.core_ok, - negativeButtonResId = R.string.course_label_do_not_allow - ), - EVENTS_DIALOG( - messageResId = R.string.course_message_course_calendar_added, - positiveButtonResId = R.string.course_label_view_events, - negativeButtonResId = R.string.course_label_done - ), - OUT_OF_SYNC_DIALOG( - titleResId = R.string.course_title_calendar_out_of_date, - messageResId = R.string.course_message_calendar_out_of_date, - positiveButtonResId = R.string.course_label_update_now, - negativeButtonResId = R.string.course_label_remove_course_calendar, - ), - LOADING_DIALOG( - titleResId = R.string.course_title_syncing_calendar - ), - NONE; -} 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 39a8098f3..3f13c506c 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 @@ -55,6 +55,8 @@ import org.koin.core.parameter.parametersOf import org.openedx.core.extension.takeIfNotEmpty import org.openedx.core.presentation.course.CourseContainerTab import org.openedx.core.presentation.global.viewBinding +import org.openedx.core.presentation.settings.calendarsync.CalendarSyncDialog +import org.openedx.core.presentation.settings.calendarsync.CalendarSyncDialogType import org.openedx.core.ui.HandleUIMessage import org.openedx.core.ui.OfflineModeDialog import org.openedx.core.ui.RoundTabsBar @@ -65,8 +67,6 @@ import org.openedx.core.ui.theme.appColors import org.openedx.course.DatesShiftedSnackBar import org.openedx.course.R import org.openedx.course.databinding.FragmentCourseContainerBinding -import org.openedx.course.presentation.calendarsync.CalendarSyncDialog -import org.openedx.course.presentation.calendarsync.CalendarSyncDialogType import org.openedx.course.presentation.dates.CourseDatesScreen import org.openedx.course.presentation.handouts.HandoutsScreen import org.openedx.course.presentation.handouts.HandoutsType diff --git a/course/src/main/java/org/openedx/course/presentation/container/CourseContainerViewModel.kt b/course/src/main/java/org/openedx/course/presentation/container/CourseContainerViewModel.kt index a757fd7ec..86d20bc3c 100644 --- a/course/src/main/java/org/openedx/course/presentation/container/CourseContainerViewModel.kt +++ b/course/src/main/java/org/openedx/course/presentation/container/CourseContainerViewModel.kt @@ -26,6 +26,9 @@ import org.openedx.core.data.storage.CorePreferences import org.openedx.core.exception.NoCachedDataException import org.openedx.core.extension.isInternetError import org.openedx.core.presentation.course.CourseContainerTab +import org.openedx.core.presentation.settings.calendarsync.CalendarSyncDialogType +import org.openedx.core.presentation.settings.calendarsync.CalendarSyncUIState +import org.openedx.core.system.CalendarManager import org.openedx.core.system.ResourceManager import org.openedx.core.system.connection.NetworkConnection import org.openedx.core.system.notifier.CalendarSyncEvent.CheckCalendarSyncEvent @@ -38,7 +41,6 @@ import org.openedx.core.system.notifier.CourseRefresh import org.openedx.core.system.notifier.CourseStructureUpdated import org.openedx.core.utils.TimeUtils import org.openedx.course.DatesShiftedSnackBar -import org.openedx.course.R import org.openedx.course.data.storage.CoursePreferences import org.openedx.course.domain.interactor.CourseInteractor import org.openedx.course.presentation.CalendarSyncDialog @@ -47,9 +49,6 @@ 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.calendarsync.CalendarManager -import org.openedx.course.presentation.calendarsync.CalendarSyncDialogType -import org.openedx.course.presentation.calendarsync.CalendarSyncUIState import java.util.Date import java.util.concurrent.atomic.AtomicReference import org.openedx.core.R as CoreR @@ -283,7 +282,7 @@ class CourseContainerViewModel( val calendarId = getCalendarId() if (calendarId == CalendarManager.CALENDAR_DOES_NOT_EXIST) { - setUiMessage(R.string.course_snackbar_course_calendar_error) + setUiMessage(CoreR.string.core_snackbar_course_calendar_error) setCalendarSyncDialogType(CalendarSyncDialogType.NONE) return @@ -314,10 +313,10 @@ class CourseContainerViewModel( if (updatedEvent) { logCalendarSyncSnackbar(CalendarSyncSnackbar.UPDATED) - setUiMessage(R.string.course_snackbar_course_calendar_updated) + setUiMessage(CoreR.string.core_snackbar_course_calendar_updated) } else if (coursePreferences.isCalendarSyncEventsDialogShown(courseName)) { logCalendarSyncSnackbar(CalendarSyncSnackbar.ADDED) - setUiMessage(R.string.course_snackbar_course_calendar_added) + setUiMessage(CoreR.string.core_snackbar_course_calendar_added) } else { coursePreferences.setCalendarSyncEventsDialogShown(courseName) setCalendarSyncDialogType(CalendarSyncDialogType.EVENTS_DIALOG) @@ -361,7 +360,7 @@ class CourseContainerViewModel( } logCalendarSyncSnackbar(CalendarSyncSnackbar.REMOVED) - setUiMessage(R.string.course_snackbar_course_calendar_removed) + setUiMessage(CoreR.string.core_snackbar_course_calendar_removed) } } 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 715584497..6e875d263 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 @@ -73,6 +73,7 @@ import org.openedx.core.extension.isNotEmptyThenLet 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.CalendarSyncUIState import org.openedx.core.ui.HandleUIMessage import org.openedx.core.ui.WindowSize import org.openedx.core.ui.WindowType @@ -86,7 +87,6 @@ import org.openedx.core.utils.TimeUtils import org.openedx.core.utils.clearTime import org.openedx.course.R import org.openedx.course.presentation.CourseRouter -import org.openedx.course.presentation.calendarsync.CalendarSyncUIState import org.openedx.course.presentation.ui.CourseDatesBanner import org.openedx.course.presentation.ui.CourseDatesBannerTablet import java.util.concurrent.atomic.AtomicReference @@ -354,7 +354,7 @@ fun CalendarSyncCard( modifier = Modifier .padding(start = 8.dp, end = 8.dp) .weight(1f), - text = stringResource(id = R.string.course_header_sync_to_calendar), + text = stringResource(id = CoreR.string.core_header_sync_to_calendar), style = MaterialTheme.appTypography.titleMedium, color = MaterialTheme.appColors.textDark ) @@ -374,7 +374,7 @@ fun CalendarSyncCard( .fillMaxWidth() .padding(top = 8.dp) .height(40.dp), - text = stringResource(id = R.string.course_body_sync_to_calendar), + text = stringResource(id = CoreR.string.core_body_sync_to_calendar), style = MaterialTheme.appTypography.bodyMedium, color = MaterialTheme.appColors.textDark, ) diff --git a/course/src/main/java/org/openedx/course/presentation/dates/CourseDatesViewModel.kt b/course/src/main/java/org/openedx/course/presentation/dates/CourseDatesViewModel.kt index 064b442a6..c72728fea 100644 --- a/course/src/main/java/org/openedx/course/presentation/dates/CourseDatesViewModel.kt +++ b/course/src/main/java/org/openedx/course/presentation/dates/CourseDatesViewModel.kt @@ -23,6 +23,9 @@ import org.openedx.core.domain.model.CourseStructure import org.openedx.core.extension.getSequentialBlocks import org.openedx.core.extension.getVerticalBlocks import org.openedx.core.extension.isInternetError +import org.openedx.core.presentation.settings.calendarsync.CalendarSyncDialogType +import org.openedx.core.presentation.settings.calendarsync.CalendarSyncUIState +import org.openedx.core.system.CalendarManager import org.openedx.core.presentation.course.CourseContainerTab import org.openedx.core.system.ResourceManager import org.openedx.core.system.notifier.CalendarSyncEvent.CheckCalendarSyncEvent @@ -35,9 +38,6 @@ 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.calendarsync.CalendarManager -import org.openedx.course.presentation.calendarsync.CalendarSyncDialogType -import org.openedx.course.presentation.calendarsync.CalendarSyncUIState import org.openedx.core.R as CoreR class CourseDatesViewModel( diff --git a/course/src/main/java/org/openedx/course/presentation/outline/CourseOutlineViewModel.kt b/course/src/main/java/org/openedx/course/presentation/outline/CourseOutlineViewModel.kt index 65ffb97fa..39bae146f 100644 --- a/course/src/main/java/org/openedx/course/presentation/outline/CourseOutlineViewModel.kt +++ b/course/src/main/java/org/openedx/course/presentation/outline/CourseOutlineViewModel.kt @@ -25,6 +25,7 @@ import org.openedx.core.module.DownloadWorkerController import org.openedx.core.module.db.DownloadDao import org.openedx.core.module.download.BaseDownloadViewModel import org.openedx.core.presentation.CoreAnalytics +import org.openedx.core.presentation.settings.calendarsync.CalendarSyncDialogType import org.openedx.core.system.ResourceManager import org.openedx.core.system.connection.NetworkConnection import org.openedx.core.system.notifier.CalendarSyncEvent.CreateCalendarSyncEvent @@ -36,7 +37,6 @@ 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.calendarsync.CalendarSyncDialogType import org.openedx.course.R as courseR class CourseOutlineViewModel( diff --git a/course/src/main/java/org/openedx/course/presentation/ui/CourseVideosUI.kt b/course/src/main/java/org/openedx/course/presentation/ui/CourseVideosUI.kt index ad46a6928..4acbfe070 100644 --- a/course/src/main/java/org/openedx/course/presentation/ui/CourseVideosUI.kt +++ b/course/src/main/java/org/openedx/course/presentation/ui/CourseVideosUI.kt @@ -67,7 +67,7 @@ import org.openedx.core.domain.model.VideoSettings import org.openedx.core.extension.toFileSize import org.openedx.core.module.download.DownloadModelsSize import org.openedx.core.presentation.course.CourseViewMode -import org.openedx.core.presentation.settings.VideoQualityType +import org.openedx.core.presentation.settings.video.VideoQualityType import org.openedx.core.ui.HandleUIMessage import org.openedx.core.ui.WindowSize import org.openedx.core.ui.WindowType diff --git a/course/src/main/res/values/strings.xml b/course/src/main/res/values/strings.xml index 596a6a77a..1104d17db 100644 --- a/course/src/main/res/values/strings.xml +++ b/course/src/main/res/values/strings.xml @@ -41,41 +41,6 @@ Course dates are not currently available. - - Sync to calendar - Automatically sync all deadlines and due dates for this course to your calendar. - - \“%s\” Would Like to Access Your Calendar - %s would like to use your calendar list to subscribe to your personalized %s calendar for this course. - Don’t allow - - Add Course Dates to Calendar - Would you like to add \“%s\” dates to your calendar? \n\nYou can edit or remove your course dates at any time from your calendar or settings. - - Syncing calendar… - - \“%s\” has been added to your phone\'s calendar. - View Events - Done - - Remove Course Dates from Calendar - Would you like to remove the \“%s\” dates from your calendar? - Remove - - Your course calendar is out of date - Your course dates have been shifted and your course calendar is no longer up to date with your new schedule. - Update Now - Remove Course Calendar - - Your course calendar has been added. - Your course calendar has been removed. - Your course calendar has been updated. - Error Adding Calendar, Please try later - - Assignment Due - - - Video player Remove course section diff --git a/course/src/test/java/org/openedx/course/presentation/container/CourseContainerViewModelTest.kt b/course/src/test/java/org/openedx/course/presentation/container/CourseContainerViewModelTest.kt index 5500c550e..b32457e4b 100644 --- a/course/src/test/java/org/openedx/course/presentation/container/CourseContainerViewModelTest.kt +++ b/course/src/test/java/org/openedx/course/presentation/container/CourseContainerViewModelTest.kt @@ -33,6 +33,7 @@ import org.openedx.core.domain.model.AppConfig import org.openedx.core.domain.model.CourseDatesCalendarSync import org.openedx.core.domain.model.CourseStructure import org.openedx.core.domain.model.CoursewareAccess +import org.openedx.core.system.CalendarManager import org.openedx.core.system.ResourceManager import org.openedx.core.system.connection.NetworkConnection import org.openedx.core.system.notifier.CourseNotifier @@ -42,7 +43,6 @@ import org.openedx.course.domain.interactor.CourseInteractor import org.openedx.course.presentation.CourseAnalytics import org.openedx.course.presentation.CourseAnalyticsEvent import org.openedx.course.presentation.CourseRouter -import org.openedx.course.presentation.calendarsync.CalendarManager import java.net.UnknownHostException import java.util.Date diff --git a/course/src/test/java/org/openedx/course/presentation/dates/CourseDatesViewModelTest.kt b/course/src/test/java/org/openedx/course/presentation/dates/CourseDatesViewModelTest.kt index 760226984..ac46234d4 100644 --- a/course/src/test/java/org/openedx/course/presentation/dates/CourseDatesViewModelTest.kt +++ b/course/src/test/java/org/openedx/course/presentation/dates/CourseDatesViewModelTest.kt @@ -37,13 +37,13 @@ import org.openedx.core.domain.model.CourseDatesResult import org.openedx.core.domain.model.CourseStructure import org.openedx.core.domain.model.CoursewareAccess import org.openedx.core.domain.model.DatesSection +import org.openedx.core.system.CalendarManager import org.openedx.core.system.ResourceManager import org.openedx.core.system.notifier.CalendarSyncEvent.CreateCalendarSyncEvent import org.openedx.core.system.notifier.CourseLoading 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.calendarsync.CalendarManager import java.net.UnknownHostException import java.util.Date diff --git a/profile/src/main/java/org/openedx/profile/presentation/ProfileRouter.kt b/profile/src/main/java/org/openedx/profile/presentation/ProfileRouter.kt index a4b194de4..fd7514bd5 100644 --- a/profile/src/main/java/org/openedx/profile/presentation/ProfileRouter.kt +++ b/profile/src/main/java/org/openedx/profile/presentation/ProfileRouter.kt @@ -1,7 +1,7 @@ package org.openedx.profile.presentation import androidx.fragment.app.FragmentManager -import org.openedx.core.presentation.settings.VideoQualityType +import org.openedx.core.presentation.settings.video.VideoQualityType import org.openedx.profile.domain.model.Account interface ProfileRouter { @@ -21,4 +21,6 @@ interface ProfileRouter { fun navigateToWebContent(fm: FragmentManager, title: String, url: String) fun navigateToManageAccount(fm: FragmentManager) + + fun navigateToCalendarSettings(fm: FragmentManager) } diff --git a/profile/src/main/java/org/openedx/profile/presentation/calendar/CalendarAccessDialogFragment.kt b/profile/src/main/java/org/openedx/profile/presentation/calendar/CalendarAccessDialogFragment.kt new file mode 100644 index 000000000..a9094c67d --- /dev/null +++ b/profile/src/main/java/org/openedx/profile/presentation/calendar/CalendarAccessDialogFragment.kt @@ -0,0 +1,161 @@ +package org.openedx.profile.presentation.calendar + +import android.content.Intent +import android.content.res.Configuration +import android.graphics.Color +import android.graphics.drawable.ColorDrawable +import android.net.Uri +import android.os.Bundle +import android.provider.Settings +import android.view.LayoutInflater +import android.view.ViewGroup +import androidx.compose.foundation.Image +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material.MaterialTheme +import androidx.compose.material.Text +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.OpenInNew +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.ComposeView +import androidx.compose.ui.platform.ViewCompositionStrategy +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.fragment.app.DialogFragment +import org.koin.android.ext.android.inject +import org.openedx.core.config.Config +import org.openedx.core.presentation.dialog.DefaultDialogBox +import org.openedx.core.ui.OpenEdXButton +import org.openedx.core.ui.OpenEdXOutlinedButton +import org.openedx.core.ui.TextIcon +import org.openedx.core.ui.theme.OpenEdXTheme +import org.openedx.core.ui.theme.appColors +import org.openedx.core.ui.theme.appTypography +import org.openedx.profile.R +import org.openedx.core.R as CoreR + +class CalendarAccessDialogFragment : DialogFragment() { + + private val config by inject() + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle?, + ) = ComposeView(requireContext()).apply { + dialog?.window?.setBackgroundDrawable(ColorDrawable(Color.TRANSPARENT)) + setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed) + setContent { + OpenEdXTheme { + CalendarAccessDialog( + onCancelClick = { + dismiss() + }, + onGrantCalendarAccessClick = { + val intent = Intent( + Settings.ACTION_APPLICATION_DETAILS_SETTINGS, + Uri.parse("package:" + config.getAppId()) + ) + startActivity(intent) + dismiss() + } + ) + } + } + } + + companion object { + const val DIALOG_TAG = "CalendarAccessDialogFragment" + + fun newInstance(): CalendarAccessDialogFragment { + return CalendarAccessDialogFragment() + } + } +} + +@Composable +private fun CalendarAccessDialog( + modifier: Modifier = Modifier, + onCancelClick: () -> Unit, + onGrantCalendarAccessClick: () -> Unit +) { + DefaultDialogBox( + modifier = modifier, + onDismissClick = onCancelClick + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(20.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + Image( + modifier = Modifier.size(24.dp), + painter = painterResource(id = CoreR.drawable.core_ic_warning), + contentDescription = null + ) + Text( + modifier = Modifier.fillMaxWidth(), + text = stringResource(id = R.string.profile_calendar_access_dialog_title), + style = MaterialTheme.appTypography.titleLarge, + color = MaterialTheme.appColors.textDark + ) + } + Text( + modifier = Modifier.fillMaxWidth(), + text = stringResource(id = R.string.profile_calendar_access_dialog_description), + style = MaterialTheme.appTypography.bodyMedium, + color = MaterialTheme.appColors.textDark + ) + OpenEdXButton( + modifier = Modifier.fillMaxWidth(), + onClick = { + onGrantCalendarAccessClick() + }, + content = { + TextIcon( + text = stringResource(id = R.string.profile_grant_access_calendar), + icon = Icons.AutoMirrored.Filled.OpenInNew, + color = MaterialTheme.appColors.buttonText, + textStyle = MaterialTheme.appTypography.labelLarge, + iconModifier = Modifier.padding(start = 4.dp) + ) + } + ) + OpenEdXOutlinedButton( + modifier = Modifier.fillMaxWidth(), + text = stringResource(id = CoreR.string.core_cancel), + backgroundColor = MaterialTheme.appColors.background, + borderColor = MaterialTheme.appColors.buttonBackground, + textColor = MaterialTheme.appColors.buttonBackground, + onClick = { + onCancelClick() + } + ) + } + } +} + +@Preview(uiMode = Configuration.UI_MODE_NIGHT_NO) +@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES) +@Composable +private fun CalendarAccessDialogPreview() { + OpenEdXTheme { + CalendarAccessDialog( + onCancelClick = { }, + onGrantCalendarAccessClick = { } + ) + } +} diff --git a/profile/src/main/java/org/openedx/profile/presentation/calendar/CalendarColor.kt b/profile/src/main/java/org/openedx/profile/presentation/calendar/CalendarColor.kt new file mode 100644 index 000000000..361fa5776 --- /dev/null +++ b/profile/src/main/java/org/openedx/profile/presentation/calendar/CalendarColor.kt @@ -0,0 +1,19 @@ +package org.openedx.profile.presentation.calendar + +import androidx.annotation.StringRes +import org.openedx.profile.R + +enum class CalendarColor( + @StringRes + val title: Int, + val color: Long +) { + ACCENT(R.string.calendar_color_accent, 0xFFD13329), + RED(R.string.calendar_color_red, 0xFFFF2967), + ORANGE(R.string.calendar_color_orange, 0xFFFF9501), + YELLOW(R.string.calendar_color_yellow, 0xFFFFCC01), + GREEN(R.string.calendar_color_green, 0xFF64DA38), + BLUE(R.string.calendar_color_blue, 0xFF1AAEF8), + PURPLE(R.string.calendar_color_purple, 0xFFCC73E1), + BROWN(R.string.calendar_color_brown, 0xFFA2845E); +} diff --git a/profile/src/main/java/org/openedx/profile/presentation/calendar/CalendarFragment.kt b/profile/src/main/java/org/openedx/profile/presentation/calendar/CalendarFragment.kt new file mode 100644 index 000000000..8a8794c94 --- /dev/null +++ b/profile/src/main/java/org/openedx/profile/presentation/calendar/CalendarFragment.kt @@ -0,0 +1,264 @@ +package org.openedx.profile.presentation.calendar + +import android.content.res.Configuration +import android.os.Bundle +import android.view.LayoutInflater +import android.view.ViewGroup +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +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.widthIn +import androidx.compose.material.Card +import androidx.compose.material.Icon +import androidx.compose.material.MaterialTheme +import androidx.compose.material.Scaffold +import androidx.compose.material.Text +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Autorenew +import androidx.compose.material.icons.rounded.CalendarToday +import androidx.compose.material.rememberScaffoldState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.ExperimentalComposeUiApi +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.platform.ComposeView +import androidx.compose.ui.platform.ViewCompositionStrategy +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.semantics.testTagsAsResourceId +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import androidx.fragment.app.Fragment +import org.koin.androidx.viewmodel.ext.android.viewModel +import org.openedx.core.ui.OpenEdXButton +import org.openedx.core.ui.Toolbar +import org.openedx.core.ui.WindowSize +import org.openedx.core.ui.WindowType +import org.openedx.core.ui.displayCutoutForLandscape +import org.openedx.core.ui.rememberWindowSize +import org.openedx.core.ui.settingsHeaderBackground +import org.openedx.core.ui.statusBarsInset +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.core.ui.windowSizeValue +import org.openedx.profile.R +import org.openedx.core.R as CoreR + +class CalendarFragment : Fragment() { + + private val viewModel by viewModel() + + private val permissionLauncher = registerForActivityResult( + ActivityResultContracts.RequestMultiplePermissions() + ) { isGranted -> + if (!isGranted.containsValue(false)) { + val dialog = NewCalendarDialogFragment.newInstance() + dialog.show( + requireActivity().supportFragmentManager, + NewCalendarDialogFragment.DIALOG_TAG + ) + } else { + val dialog = CalendarAccessDialogFragment.newInstance() + dialog.show( + requireActivity().supportFragmentManager, + CalendarAccessDialogFragment.DIALOG_TAG + ) + } + } + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ) = ComposeView(requireContext()).apply { + setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed) + setContent { + OpenEdXTheme { + val windowSize = rememberWindowSize() + + CalendarScreen( + windowSize = windowSize, + setUpCalendarSync = { + viewModel.setUpCalendarSync(permissionLauncher) + }, + onBackClick = { + requireActivity().supportFragmentManager.popBackStack() + } + ) + } + } + } +} + +@OptIn(ExperimentalComposeUiApi::class) +@Composable +private fun CalendarScreen( + windowSize: WindowSize, + setUpCalendarSync: () -> Unit, + onBackClick: () -> Unit +) { + val scaffoldState = rememberScaffoldState() + + Scaffold( + modifier = Modifier + .fillMaxSize() + .semantics { + testTagsAsResourceId = true + }, + scaffoldState = scaffoldState + ) { paddingValues -> + + val contentWidth by remember(key1 = windowSize) { + mutableStateOf( + windowSize.windowSizeValue( + expanded = Modifier.widthIn(Dp.Unspecified, 420.dp), + compact = Modifier + .fillMaxWidth() + .padding(horizontal = 24.dp) + ) + ) + } + + val topBarWidth by remember(key1 = windowSize) { + mutableStateOf( + windowSize.windowSizeValue( + expanded = Modifier.widthIn(Dp.Unspecified, 560.dp), + compact = Modifier + .fillMaxWidth() + ) + ) + } + + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.TopCenter + ) { + Column( + modifier = Modifier + .fillMaxSize() + .padding(paddingValues) + .settingsHeaderBackground() + .statusBarsInset(), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Toolbar( + modifier = topBarWidth + .displayCutoutForLandscape(), + label = stringResource(id = R.string.profile_dates_and_calendar), + canShowBackBtn = true, + labelTint = MaterialTheme.appColors.settingsTitleContent, + iconTint = MaterialTheme.appColors.settingsTitleContent, + onBackClick = onBackClick + ) + + Box( + modifier = Modifier + .fillMaxSize() + .clip(MaterialTheme.appShapes.screenBackgroundShape) + .background(MaterialTheme.appColors.background) + .displayCutoutForLandscape(), + contentAlignment = Alignment.TopCenter + ) { + Column( + modifier = contentWidth.padding(vertical = 28.dp), + ) { + Text( + modifier = Modifier.testTag("txt_settings"), + text = stringResource(id = CoreR.string.core_settings), + style = MaterialTheme.appTypography.labelLarge, + color = MaterialTheme.appColors.textSecondary + ) + Spacer(modifier = Modifier.height(14.dp)) + Card( + shape = MaterialTheme.appShapes.cardShape, + elevation = 0.dp, + backgroundColor = MaterialTheme.appColors.cardViewBackground + ) { + Column( + modifier = Modifier + .padding(horizontal = 20.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Box( + modifier = Modifier + .padding(vertical = 28.dp), + contentAlignment = Alignment.Center + ) { + Icon( + modifier = Modifier + .fillMaxWidth() + .height(148.dp), + tint = MaterialTheme.appColors.textDark, + imageVector = Icons.Rounded.CalendarToday, + contentDescription = null + ) + Icon( + modifier = Modifier + .fillMaxWidth() + .padding(top = 30.dp) + .height(60.dp), + tint = MaterialTheme.appColors.textDark, + imageVector = Icons.Default.Autorenew, + contentDescription = null + ) + } + Text( + modifier = Modifier.fillMaxWidth(), + textAlign = TextAlign.Center, + text = stringResource(id = R.string.profile_calendar_sync), + style = MaterialTheme.appTypography.titleMedium, + color = MaterialTheme.appColors.textDark + ) + Spacer(modifier = Modifier.height(16.dp)) + Text( + modifier = Modifier.fillMaxWidth(), + textAlign = TextAlign.Center, + text = stringResource(id = R.string.profile_calendar_sync_description), + style = MaterialTheme.appTypography.labelLarge, + color = MaterialTheme.appColors.textDark + ) + Spacer(modifier = Modifier.height(16.dp)) + OpenEdXButton( + modifier = Modifier.fillMaxWidth(0.75f), + text = stringResource(id = R.string.profile_set_up_calendar_sync), + onClick = { + setUpCalendarSync() + } + ) + Spacer(modifier = Modifier.height(24.dp)) + } + } + } + } + } + } + } +} + +@Preview(uiMode = Configuration.UI_MODE_NIGHT_NO) +@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES) +@Composable +private fun CalendarScreenPreview() { + OpenEdXTheme { + CalendarScreen( + windowSize = WindowSize(WindowType.Compact, WindowType.Compact), + setUpCalendarSync = {}, + onBackClick = {} + ) + } +} diff --git a/profile/src/main/java/org/openedx/profile/presentation/calendar/CalendarViewModel.kt b/profile/src/main/java/org/openedx/profile/presentation/calendar/CalendarViewModel.kt new file mode 100644 index 000000000..316b689b4 --- /dev/null +++ b/profile/src/main/java/org/openedx/profile/presentation/calendar/CalendarViewModel.kt @@ -0,0 +1,14 @@ +package org.openedx.profile.presentation.calendar + +import androidx.activity.result.ActivityResultLauncher +import org.openedx.core.BaseViewModel +import org.openedx.core.system.CalendarManager + +class CalendarViewModel( + private val calendarManager: CalendarManager +) : BaseViewModel() { + + fun setUpCalendarSync(permissionLauncher: ActivityResultLauncher>) { + permissionLauncher.launch(calendarManager.permissions) + } +} diff --git a/profile/src/main/java/org/openedx/profile/presentation/calendar/NewCalendarDialogFragment.kt b/profile/src/main/java/org/openedx/profile/presentation/calendar/NewCalendarDialogFragment.kt new file mode 100644 index 000000000..bfd453f5c --- /dev/null +++ b/profile/src/main/java/org/openedx/profile/presentation/calendar/NewCalendarDialogFragment.kt @@ -0,0 +1,390 @@ +package org.openedx.profile.presentation.calendar + +import android.content.Context +import android.content.res.Configuration +import android.graphics.Color +import android.graphics.drawable.ColorDrawable +import android.os.Bundle +import android.view.LayoutInflater +import android.view.ViewGroup +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material.Divider +import androidx.compose.material.DropdownMenu +import androidx.compose.material.DropdownMenuItem +import androidx.compose.material.Icon +import androidx.compose.material.MaterialTheme +import androidx.compose.material.OutlinedTextField +import androidx.compose.material.Text +import androidx.compose.material.TextFieldDefaults +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Close +import androidx.compose.material.icons.filled.ExpandMore +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.rotate +import androidx.compose.ui.layout.onSizeChanged +import androidx.compose.ui.platform.ComposeView +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.LocalFocusManager +import androidx.compose.ui.platform.ViewCompositionStrategy +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.input.TextFieldValue +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.fragment.app.DialogFragment +import org.openedx.core.presentation.dialog.DefaultDialogBox +import org.openedx.core.ui.OpenEdXButton +import org.openedx.core.ui.OpenEdXOutlinedButton +import org.openedx.core.ui.crop +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.profile.R +import androidx.compose.ui.graphics.Color as ComposeColor +import org.openedx.core.R as CoreR + +class NewCalendarDialogFragment : DialogFragment() { + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle?, + ) = ComposeView(requireContext()).apply { + dialog?.window?.setBackgroundDrawable(ColorDrawable(Color.TRANSPARENT)) + setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed) + setContent { + OpenEdXTheme { + NewCalendarDialog( + onCancelClick = { + dismiss() + }, + onBeginSyncingClick = { calendarName, calendarColor -> + //TODO Create calendar and sync events + } + ) + } + } + } + + companion object { + const val DIALOG_TAG = "NewCalendarDialogFragment" + + fun newInstance(): NewCalendarDialogFragment { + return NewCalendarDialogFragment() + } + + fun getDefaultCalendarName(context: Context): String { + return "${context.getString(CoreR.string.app_name)} ${context.getString(R.string.profile_course_dates)}" + } + } +} + +@Composable +private fun NewCalendarDialog( + modifier: Modifier = Modifier, + onCancelClick: () -> Unit, + onBeginSyncingClick: (calendarName: String, calendarColor: CalendarColor) -> Unit +) { + val context = LocalContext.current + var calendarName by rememberSaveable { + mutableStateOf("") + } + var calendarColor by rememberSaveable { + mutableStateOf(CalendarColor.ACCENT) + } + DefaultDialogBox( + modifier = modifier, + onDismissClick = onCancelClick + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(20.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + Row( + verticalAlignment = Alignment.CenterVertically + ) { + Text( + modifier = Modifier.weight(1f), + text = stringResource(id = R.string.profile_new_calendar), + color = MaterialTheme.appColors.textDark, + style = MaterialTheme.appTypography.titleLarge + ) + Icon( + modifier = Modifier + .size(24.dp) + .clickable { + onCancelClick() + }, + imageVector = Icons.Default.Close, + contentDescription = null, + tint = MaterialTheme.appColors.primary + ) + } + CalendarNameTextField( + onValueChanged = { + calendarName = it + } + ) + ColorDropdown( + onValueChanged = { + calendarColor = it + } + ) + Text( + modifier = Modifier.fillMaxWidth(), + text = stringResource(id = R.string.profile_new_calendar_description), + style = MaterialTheme.appTypography.bodyMedium, + textAlign = TextAlign.Center, + color = MaterialTheme.appColors.textDark + ) + OpenEdXOutlinedButton( + modifier = Modifier.fillMaxWidth(), + text = stringResource(id = CoreR.string.core_cancel), + backgroundColor = MaterialTheme.appColors.background, + borderColor = MaterialTheme.appColors.buttonBackground, + textColor = MaterialTheme.appColors.buttonBackground, + onClick = { + onCancelClick() + } + ) + OpenEdXButton( + modifier = Modifier.fillMaxWidth(), + text = stringResource(id = R.string.profile_begin_syncing), + onClick = { + onBeginSyncingClick( + calendarName.ifEmpty { NewCalendarDialogFragment.getDefaultCalendarName(context) }, + calendarColor + ) + } + ) + } + } +} + +@Composable +private fun CalendarNameTextField( + modifier: Modifier = Modifier, + onValueChanged: (String) -> Unit +) { + val focusManager = LocalFocusManager.current + var textFieldValue by rememberSaveable(stateSaver = TextFieldValue.Saver) { + mutableStateOf( + TextFieldValue("") + ) + } + + Column { + Text( + modifier = Modifier.fillMaxWidth(), + text = stringResource(id = R.string.profile_calendar_name), + color = MaterialTheme.appColors.textPrimary, + style = MaterialTheme.appTypography.labelLarge + ) + Spacer(modifier = Modifier.height(8.dp)) + OutlinedTextField( + modifier = modifier + .fillMaxWidth() + .height(48.dp), + value = textFieldValue, + onValueChange = { + textFieldValue = it + onValueChanged(it.text.trim()) + }, + colors = TextFieldDefaults.outlinedTextFieldColors( + unfocusedBorderColor = MaterialTheme.appColors.textFieldBorder + ), + shape = MaterialTheme.appShapes.textFieldShape, + placeholder = { + Text( + text = NewCalendarDialogFragment.getDefaultCalendarName(LocalContext.current), + color = MaterialTheme.appColors.textFieldHint, + style = MaterialTheme.appTypography.bodyMedium + ) + }, + keyboardOptions = KeyboardOptions.Default.copy( + keyboardType = KeyboardType.Text, + imeAction = ImeAction.Next + ), + keyboardActions = KeyboardActions { + focusManager.clearFocus() + }, + textStyle = MaterialTheme.appTypography.bodyMedium, + singleLine = true + ) + } +} + +@Composable +private fun ColorDropdown( + modifier: Modifier = Modifier, + onValueChanged: (CalendarColor) -> Unit +) { + val density = LocalDensity.current + var expanded by remember { mutableStateOf(false) } + var currentValue by remember { mutableStateOf(CalendarColor.ACCENT) } + var dropdownWidth by remember { mutableStateOf(300.dp) } + val colorArrowRotation by animateFloatAsState( + targetValue = if (expanded) 180f else 0f, + label = "" + ) + + Column( + modifier = modifier + ) { + Text( + modifier = Modifier.fillMaxWidth(), + text = stringResource(id = R.string.profile_color), + color = MaterialTheme.appColors.textPrimary, + style = MaterialTheme.appTypography.labelLarge + ) + Spacer(modifier = Modifier.height(8.dp)) + Row( + modifier = Modifier + .fillMaxWidth() + .height(48.dp) + .clip(MaterialTheme.appShapes.textFieldShape) + .border( + 1.dp, + MaterialTheme.appColors.textFieldBorder, + MaterialTheme.appShapes.textFieldShape + ) + .onSizeChanged { + dropdownWidth = with(density) { it.width.toDp() } + } + .clickable { + expanded = true + }, + verticalAlignment = Alignment.CenterVertically + ) { + ColorCircle( + modifier = Modifier + .padding(start = 16.dp), + color = ComposeColor(currentValue.color) + ) + Text( + modifier = Modifier + .weight(1f) + .padding(horizontal = 8.dp), + text = stringResource(id = currentValue.title), + color = MaterialTheme.appColors.textDark, + style = MaterialTheme.appTypography.bodyMedium + ) + Icon( + modifier = Modifier + .padding(end = 16.dp) + .rotate(colorArrowRotation), + imageVector = Icons.Default.ExpandMore, + tint = MaterialTheme.appColors.textDark, + contentDescription = null + ) + } + + MaterialTheme( + colors = MaterialTheme.colors.copy(surface = MaterialTheme.appColors.background), + shapes = MaterialTheme.shapes.copy(MaterialTheme.appShapes.textFieldShape) + ) { + Spacer(modifier = Modifier.padding(top = 4.dp)) + DropdownMenu( + modifier = Modifier + .crop(vertical = 8.dp) + .height(240.dp) + .width(dropdownWidth) + .border( + 1.dp, + MaterialTheme.appColors.textFieldBorder, + MaterialTheme.appShapes.textFieldShape + ) + .crop(vertical = 8.dp), + expanded = expanded, + onDismissRequest = { expanded = false } + ) { + for ((index, calendarColor) in CalendarColor.entries.withIndex()) { + DropdownMenuItem( + modifier = Modifier + .background(MaterialTheme.appColors.background), + onClick = { + currentValue = calendarColor + expanded = false + onValueChanged(CalendarColor.entries[index]) + } + ) { + Row( + horizontalArrangement = Arrangement.spacedBy(12.dp) + ) { + ColorCircle( + color = ComposeColor(calendarColor.color) + ) + Text( + text = stringResource(id = calendarColor.title), + style = MaterialTheme.appTypography.titleSmall, + color = MaterialTheme.appColors.textDark + ) + } + } + if (index < CalendarColor.entries.lastIndex) { + Divider( + modifier = Modifier.padding(horizontal = 16.dp), + color = MaterialTheme.appColors.divider + ) + } + } + } + } + } +} + +@Composable +private fun ColorCircle( + modifier: Modifier = Modifier, + color: ComposeColor +) { + Box( + modifier = modifier + .size(18.dp) + .clip(CircleShape) + .background(color) + ) +} + +@Preview(uiMode = Configuration.UI_MODE_NIGHT_NO) +@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES) +@Composable +private fun NewCalendarDialogPreview() { + OpenEdXTheme { + NewCalendarDialog( + onCancelClick = { }, + onBeginSyncingClick = { _, _ -> } + ) + } +} diff --git a/profile/src/main/java/org/openedx/profile/presentation/settings/SettingsFragment.kt b/profile/src/main/java/org/openedx/profile/presentation/settings/SettingsFragment.kt index fbdd0b4af..7ac402330 100644 --- a/profile/src/main/java/org/openedx/profile/presentation/settings/SettingsFragment.kt +++ b/profile/src/main/java/org/openedx/profile/presentation/settings/SettingsFragment.kt @@ -83,11 +83,17 @@ class SettingsFragment : Fragment() { ) } - SettingsScreenAction.ManageAccount -> { + SettingsScreenAction.ManageAccountClick -> { viewModel.manageAccountClicked( requireActivity().supportFragmentManager ) } + + SettingsScreenAction.CalendarSettingsClick -> { + viewModel.calendarSettingsClicked( + requireActivity().supportFragmentManager + ) + } } } ) @@ -112,6 +118,7 @@ internal interface SettingsScreenAction { object TermsClick : SettingsScreenAction object SupportClick : SettingsScreenAction object VideoSettingsClick : SettingsScreenAction - object ManageAccount : SettingsScreenAction + object ManageAccountClick : SettingsScreenAction + object CalendarSettingsClick : SettingsScreenAction } diff --git a/profile/src/main/java/org/openedx/profile/presentation/settings/SettingsScreenUI.kt b/profile/src/main/java/org/openedx/profile/presentation/settings/SettingsScreenUI.kt index ea6471330..f94064d30 100644 --- a/profile/src/main/java/org/openedx/profile/presentation/settings/SettingsScreenUI.kt +++ b/profile/src/main/java/org/openedx/profile/presentation/settings/SettingsScreenUI.kt @@ -21,7 +21,6 @@ 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 import androidx.compose.material.Icon import androidx.compose.material.IconButton import androidx.compose.material.MaterialTheme @@ -68,6 +67,7 @@ import org.openedx.core.ui.theme.appShapes import org.openedx.core.ui.theme.appTypography import org.openedx.core.ui.windowSizeValue import org.openedx.profile.domain.model.Configuration +import org.openedx.profile.presentation.ui.SettingsDivider import org.openedx.profile.presentation.ui.SettingsItem import org.openedx.profile.R as profileR @@ -170,14 +170,19 @@ internal fun SettingsScreen( Spacer(Modifier.height(30.dp)) ManageAccountSection(onManageAccountClick = { - onAction(SettingsScreenAction.ManageAccount) + onAction(SettingsScreenAction.ManageAccountClick) }) Spacer(modifier = Modifier.height(24.dp)) - SettingsSection(onVideoSettingsClick = { - onAction(SettingsScreenAction.VideoSettingsClick) - }) + SettingsSection( + onVideoSettingsClick = { + onAction(SettingsScreenAction.VideoSettingsClick) + }, + onCalendarSettingsClick = { + onAction(SettingsScreenAction.CalendarSettingsClick) + } + ) Spacer(modifier = Modifier.height(24.dp)) @@ -205,7 +210,10 @@ internal fun SettingsScreen( } @Composable -private fun SettingsSection(onVideoSettingsClick: () -> Unit) { +private fun SettingsSection( + onVideoSettingsClick: () -> Unit, + onCalendarSettingsClick: () -> Unit +) { Column { Text( modifier = Modifier.testTag("txt_settings"), @@ -225,6 +233,11 @@ private fun SettingsSection(onVideoSettingsClick: () -> Unit) { text = stringResource(id = profileR.string.profile_video), onClick = onVideoSettingsClick ) + SettingsDivider() + SettingsItem( + text = stringResource(id = profileR.string.profile_dates_and_calendar), + onClick = onCalendarSettingsClick + ) } } } @@ -273,46 +286,31 @@ private fun SupportInfoSection( SettingsItem(text = stringResource(id = profileR.string.profile_contact_support)) { onAction(SettingsScreenAction.SupportClick) } - Divider( - modifier = Modifier.padding(vertical = 4.dp), - color = MaterialTheme.appColors.divider - ) + SettingsDivider() } if (uiState.configuration.agreementUrls.tosUrl.isNotBlank()) { SettingsItem(text = stringResource(id = R.string.core_terms_of_use)) { onAction(SettingsScreenAction.TermsClick) } - Divider( - modifier = Modifier.padding(vertical = 4.dp), - color = MaterialTheme.appColors.divider - ) + SettingsDivider() } if (uiState.configuration.agreementUrls.privacyPolicyUrl.isNotBlank()) { SettingsItem(text = stringResource(id = R.string.core_privacy_policy)) { onAction(SettingsScreenAction.PrivacyPolicyClick) } - Divider( - modifier = Modifier.padding(vertical = 4.dp), - color = MaterialTheme.appColors.divider - ) + SettingsDivider() } if (uiState.configuration.agreementUrls.cookiePolicyUrl.isNotBlank()) { SettingsItem(text = stringResource(id = R.string.core_cookie_policy)) { onAction(SettingsScreenAction.CookiePolicyClick) } - Divider( - modifier = Modifier.padding(vertical = 4.dp), - color = MaterialTheme.appColors.divider - ) + SettingsDivider() } if (uiState.configuration.agreementUrls.dataSellConsentUrl.isNotBlank()) { SettingsItem(text = stringResource(id = R.string.core_data_sell)) { onAction(SettingsScreenAction.DataSellClick) } - Divider( - modifier = Modifier.padding(vertical = 4.dp), - color = MaterialTheme.appColors.divider - ) + SettingsDivider() } if (uiState.configuration.faqUrl.isNotBlank()) { val uriHandler = LocalUriHandler.current @@ -323,10 +321,7 @@ private fun SupportInfoSection( uriHandler.openUri(uiState.configuration.faqUrl) onAction(SettingsScreenAction.FaqClick) } - Divider( - modifier = Modifier.padding(vertical = 4.dp), - color = MaterialTheme.appColors.divider - ) + SettingsDivider() } AppVersionItem( versionName = uiState.configuration.versionName, @@ -344,16 +339,17 @@ private fun LogoutButton(onClick: () -> Unit) { Card( modifier = Modifier .testTag("btn_logout") - .fillMaxWidth() - .clickable { - onClick() - }, + .fillMaxWidth(), shape = MaterialTheme.appShapes.cardShape, elevation = 0.dp, backgroundColor = MaterialTheme.appColors.cardViewBackground ) { Row( - modifier = Modifier.padding(20.dp), + modifier = Modifier + .clickable { + onClick() + } + .padding(20.dp), horizontalArrangement = Arrangement.SpaceBetween ) { Text( diff --git a/profile/src/main/java/org/openedx/profile/presentation/settings/SettingsViewModel.kt b/profile/src/main/java/org/openedx/profile/presentation/settings/SettingsViewModel.kt index 2c7471ebd..9715eb774 100644 --- a/profile/src/main/java/org/openedx/profile/presentation/settings/SettingsViewModel.kt +++ b/profile/src/main/java/org/openedx/profile/presentation/settings/SettingsViewModel.kt @@ -185,6 +185,10 @@ class SettingsViewModel( router.navigateToManageAccount(fragmentManager) } + fun calendarSettingsClicked(fragmentManager: FragmentManager) { + router.navigateToCalendarSettings(fragmentManager) + } + fun restartApp(fragmentManager: FragmentManager) { router.restartApp( fragmentManager, diff --git a/profile/src/main/java/org/openedx/profile/presentation/ui/SettingsUI.kt b/profile/src/main/java/org/openedx/profile/presentation/ui/SettingsUI.kt index 6960a0864..df6c719ca 100644 --- a/profile/src/main/java/org/openedx/profile/presentation/ui/SettingsUI.kt +++ b/profile/src/main/java/org/openedx/profile/presentation/ui/SettingsUI.kt @@ -6,6 +6,7 @@ import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size +import androidx.compose.material.Divider import androidx.compose.material.Icon import androidx.compose.material.MaterialTheme import androidx.compose.material.Text @@ -38,7 +39,10 @@ fun SettingsItem( .testTag("btn_${text.tagId()}") .fillMaxWidth() .clickable { onClick() } - .padding(20.dp), + .padding( + vertical = 24.dp, + horizontal = 20.dp + ), horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically ) { @@ -59,3 +63,14 @@ fun SettingsItem( ) } } + +@Composable +fun SettingsDivider() { + Divider( + modifier = Modifier + .padding( + horizontal = 20.dp + ), + color = MaterialTheme.appColors.divider + ) +} diff --git a/profile/src/main/java/org/openedx/profile/presentation/video/VideoSettingsViewModel.kt b/profile/src/main/java/org/openedx/profile/presentation/video/VideoSettingsViewModel.kt index b98ec8709..f0ed7622a 100644 --- a/profile/src/main/java/org/openedx/profile/presentation/video/VideoSettingsViewModel.kt +++ b/profile/src/main/java/org/openedx/profile/presentation/video/VideoSettingsViewModel.kt @@ -10,7 +10,7 @@ import kotlinx.coroutines.launch import org.openedx.core.BaseViewModel import org.openedx.core.data.storage.CorePreferences import org.openedx.core.domain.model.VideoSettings -import org.openedx.core.presentation.settings.VideoQualityType +import org.openedx.core.presentation.settings.video.VideoQualityType import org.openedx.core.system.notifier.VideoNotifier import org.openedx.core.system.notifier.VideoQualityChanged import org.openedx.profile.presentation.ProfileAnalytics diff --git a/profile/src/main/res/values/strings.xml b/profile/src/main/res/values/strings.xml index efdb04c30..60f0e4060 100644 --- a/profile/src/main/res/values/strings.xml +++ b/profile/src/main/res/values/strings.xml @@ -37,7 +37,28 @@ Contact Support Support Video + Dates & Calendar Wi-fi only download Only download content when wi-fi is turned on + Calendar Sync + Set up calendar sync to show your upcoming assignments and course milestones on your calendar. New assignments and shifted course dates will sync automatically + Set Up Calendar Sync + Calendar Access + To show upcoming assignments and course milestones on your calendar, we need permission to access your calendar. + Grant Calendar Access + New Calendar + Upcoming assignments for active courses will appear on this calendar + Begin Syncing + Calendar Name + Red + Orange + Yellow + Green + Blue + Purple + Brown + Accent + Course Dates + Color From 442496d97cb9826e7b44e285bfdf9fb16b44ae62 Mon Sep 17 00:00:00 2001 From: PavloNetrebchuk Date: Thu, 30 May 2024 12:54:49 +0300 Subject: [PATCH 10/18] fix: DiscussionTopicsViewModelTest.kt jUnit test --- .../topics/DiscussionTopicsViewModelTest.kt | 22 +++++++++++++++---- 1 file changed, 18 insertions(+), 4 deletions(-) diff --git a/discussion/src/test/java/org/openedx/discussion/presentation/topics/DiscussionTopicsViewModelTest.kt b/discussion/src/test/java/org/openedx/discussion/presentation/topics/DiscussionTopicsViewModelTest.kt index b74cc6644..9fc56f6af 100644 --- a/discussion/src/test/java/org/openedx/discussion/presentation/topics/DiscussionTopicsViewModelTest.kt +++ b/discussion/src/test/java/org/openedx/discussion/presentation/topics/DiscussionTopicsViewModelTest.kt @@ -26,6 +26,7 @@ import org.junit.rules.TestRule import org.openedx.core.BlockType import org.openedx.core.R import org.openedx.core.UIMessage +import org.openedx.core.domain.model.AssignmentProgress import org.openedx.core.domain.model.Block import org.openedx.core.domain.model.BlockCounts import org.openedx.core.domain.model.CourseStructure @@ -56,6 +57,12 @@ class DiscussionTopicsViewModelTest { private val noInternet = "Slow or no internet connection" private val somethingWrong = "Something went wrong" + private val assignmentProgress = AssignmentProgress( + assignmentType = "Homework", + numPointsEarned = 1f, + numPointsPossible = 3f + ) + private val blocks = listOf( Block( id = "id", @@ -71,7 +78,9 @@ class DiscussionTopicsViewModelTest { blockCounts = BlockCounts(0), descendants = listOf("1", "id1"), descendantsType = BlockType.HTML, - completion = 0.0 + completion = 0.0, + assignmentProgress = assignmentProgress, + due = Date() ), Block( id = "id1", @@ -87,7 +96,9 @@ class DiscussionTopicsViewModelTest { blockCounts = BlockCounts(0), descendants = listOf("id2"), descendantsType = BlockType.HTML, - completion = 0.0 + completion = 0.0, + assignmentProgress = assignmentProgress, + due = Date() ), Block( id = "id2", @@ -103,7 +114,9 @@ class DiscussionTopicsViewModelTest { blockCounts = BlockCounts(0), descendants = emptyList(), descendantsType = BlockType.HTML, - completion = 0.0 + completion = 0.0, + assignmentProgress = assignmentProgress, + due = Date() ) ) private val courseStructure = CourseStructure( @@ -127,7 +140,8 @@ class DiscussionTopicsViewModelTest { ), media = null, certificate = null, - isSelfPaced = false + isSelfPaced = false, + progress = null ) @Before From 23dcabff73f520da941f6852926286fabc9ed507 Mon Sep 17 00:00:00 2001 From: PavloNetrebchuk Date: Thu, 30 May 2024 13:58:58 +0300 Subject: [PATCH 11/18] fix: assignment dates --- .../java/org/openedx/core/utils/TimeUtils.kt | 29 +++++++++++++++++++ core/src/main/res/values/strings.xml | 4 +++ .../course/presentation/ui/CourseUI.kt | 2 +- course/src/main/res/values/strings.xml | 2 +- 4 files changed, 35 insertions(+), 2 deletions(-) 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 d77a1ab5e..e23135e47 100644 --- a/core/src/main/java/org/openedx/core/utils/TimeUtils.kt +++ b/core/src/main/java/org/openedx/core/utils/TimeUtils.kt @@ -242,6 +242,35 @@ object TimeUtils { } } + fun getAssignmentFormattedDate(context: Context, date: Date): String { + val inputDate = Calendar.getInstance().also { + it.time = date + it.clearTimeComponents() + } + val daysDifference = getDayDifference(inputDate) + + return when { + daysDifference == 0 -> { + context.getString(R.string.core_date_format_assignment_due_today) + } + + daysDifference == 1 -> { + context.getString(R.string.core_date_format_assignment_due_tomorrow) + } + + daysDifference <= -1 -> { + context.getString(R.string.core_date_format_past_due_assignment) + } + + else -> { + context.getString( + R.string.core_date_format_assignment_due_in, + ceil(daysDifference.toDouble()).toInt().toString() + ) + } + } + } + /** * Returns the number of days difference between the given date and the current date. */ diff --git a/core/src/main/res/values/strings.xml b/core/src/main/res/values/strings.xml index 668c61935..7247f0fbe 100644 --- a/core/src/main/res/values/strings.xml +++ b/core/src/main/res/values/strings.xml @@ -96,6 +96,10 @@ Tomorrow Yesterday %1$s days ago + Past due assignment + Due Today + Due Tomorrow + Due in %1$s days %d Item Hidden %d Items Hidden 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 a3d38e0e3..aae470213 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 @@ -751,7 +751,7 @@ fun CourseSubSectionItem( val iconColor = if (block.isCompleted()) MaterialTheme.appColors.successGreen else MaterialTheme.appColors.onSurface - val due = block.due?.let { TimeUtils.getCourseFormattedDate(LocalContext.current, it) } + val due = block.due?.let { TimeUtils.getAssignmentFormattedDate(LocalContext.current, it) } val isAssignmentEnable = !block.isCompleted() && block.assignmentProgress != null && !due.isNullOrEmpty() Column( modifier = modifier diff --git a/course/src/main/res/values/strings.xml b/course/src/main/res/values/strings.xml index 1104d17db..337a63038 100644 --- a/course/src/main/res/values/strings.xml +++ b/course/src/main/res/values/strings.xml @@ -57,6 +57,6 @@ Are you sure you want to delete all video(s) for \"%s\"? Are you sure you want to delete video(s) for \"%s\"? %1$s of %2$s assignments complete - %1$s - Due %2$s - %3$d / %4$d + %1$s - %2$s - %3$d / %4$d From 654bcf06293cc73f842e7ee8e72fdfabc08f0788 Mon Sep 17 00:00:00 2001 From: PavloNetrebchuk <141041606+PavloNetrebchuk@users.noreply.github.com> Date: Thu, 30 May 2024 11:11:05 +0300 Subject: [PATCH 12/18] feat: [FC-0047] Improved Dashboard Level Navigation (#308) * feat: Created Learn screen. Added course/program navigation. Added endpoint for UserCourses screen. * feat: Added primary course card * feat: Added start/resume course button * feat: Added alignment items * feat: Fix future assignment date, add courses list, add onSearch and onCourse clicks * feat: Add feature flag for enabling new/old dashboard screen, add UserCoursesScreen onClick methods * feat: Create AllEnrolledCoursesFragment. Add endpoint parameters * feat: AllEnrolledCoursesFragment UI * feat: Minor code refactoring, show cached data if no internet connection * feat: UserCourses screen data caching * feat: Dashboard * refactor: Dashboard type flag change, start course button change * feat: Added programs fragment to LearnFragment viewPager * feat: Empty states and settings button * fix: Number of courses * fix: Minor UI changes * fix: Fixes according to designer feedback * fix: Fixes after demo * refactor: Move CourseContainerTab * fix: Fixes according to PR feedback * fix: Fixes according to PR feedback * feat: added a patch from Omer Habib * fix: Fixes according to PR feedback --- .../main/java/org/openedx/app/AppRouter.kt | 25 +- .../org/openedx/app/InDevelopmentFragment.kt | 56 -- .../main/java/org/openedx/app/MainFragment.kt | 42 +- .../java/org/openedx/app/MainViewModel.kt | 18 +- .../main/java/org/openedx/app/di/AppModule.kt | 3 + .../java/org/openedx/app/di/ScreenModule.kt | 18 +- app/src/main/res/color/bottom_nav_color.xml | 5 + app/src/main/res/drawable/app_ic_rows.xml | 44 +- app/src/main/res/layout/fragment_main.xml | 2 + app/src/main/res/menu/bottom_view_menu.xml | 24 +- app/src/main/res/values-uk/strings.xml | 4 +- app/src/main/res/values/strings.xml | 4 +- .../core/adapter/NavigationFragmentAdapter.kt | 4 +- .../java/org/openedx/core/config/Config.kt | 5 + .../openedx/core/config/DashboardConfig.kt | 16 + .../org/openedx/core/data/api/CourseApi.kt | 9 + .../core/data/model/CourseAssignments.kt | 30 + .../core/data/model/CourseDateBlock.kt | 41 +- .../core/data/model/CourseEnrollments.kt | 26 +- .../openedx/core/data/model/CourseStatus.kt | 30 + .../openedx/core/data/model/EnrolledCourse.kt | 20 +- .../org/openedx/core/data/model/Progress.kt | 15 +- .../room/discovery/EnrolledCourseEntity.kt | 101 +- .../core/domain/model/CourseAssignments.kt | 10 + .../core/domain/model/CourseDateBlock.kt | 5 +- .../core/domain/model/CourseEnrollments.kt | 7 + .../openedx/core/domain/model/CourseStatus.kt | 12 + .../core/domain/model/EnrolledCourse.kt | 3 + .../org/openedx/core/domain/model/Progress.kt | 7 +- .../org/openedx/core/module/DownloadWorker.kt | 9 +- .../openedx/core/module/TranscriptManager.kt | 18 +- .../core/system/notifier/CourseNotifier.kt | 4 +- .../core/system/notifier/CourseOpenBlock.kt | 3 + .../core/system/notifier/CourseRefresh.kt | 5 - .../core/system/notifier/RefreshDates.kt | 3 + .../system/notifier/RefreshDiscussions.kt | 3 + .../java/org/openedx/core/ui/ComposeCommon.kt | 54 +- .../org/openedx/core/ui/ComposeExtensions.kt | 11 + .../main/java/org/openedx/core/ui/TabItem.kt | 2 +- .../java/org/openedx/core/ui/theme/Type.kt | 8 + .../java/org/openedx/core/utils/FileUtil.kt | 21 +- .../java/org/openedx/core/utils/TimeUtils.kt | 4 +- .../main/res/drawable/core_ic_settings.xml | 20 - .../res/drawable/ic_core_chapter_icon.xml | 30 + core/src/main/res/values-night/colors.xml | 4 +- core/src/main/res/values-uk/strings.xml | 4 +- core/src/main/res/values/colors.xml | 4 +- core/src/main/res/values/strings.xml | 5 +- .../course/data/storage/CourseConverter.kt | 13 + .../container/CollapsingLayout.kt | 4 +- .../container/CourseContainerFragment.kt | 44 +- .../container}/CourseContainerTab.kt | 16 +- .../container/CourseContainerViewModel.kt | 17 +- .../presentation/dates/CourseDatesScreen.kt | 40 +- .../dates/CourseDatesViewModel.kt | 11 +- .../outline/CourseOutlineScreen.kt | 76 +- .../outline/CourseOutlineViewModel.kt | 58 +- .../section/CourseSectionFragment.kt | 11 +- .../course/presentation/ui/CourseUI.kt | 2 +- .../course/presentation/ui/CourseVideosUI.kt | 58 +- .../videos/CourseVideoViewModel.kt | 2 + .../res/drawable/ic_course_chapter_icon.xml | 31 - course/src/main/res/values/strings.xml | 6 + .../container/CourseContainerViewModelTest.kt | 7 + .../dates/CourseDatesViewModelTest.kt | 14 +- .../outline/CourseOutlineViewModelTest.kt | 11 + .../videos/CourseVideoViewModelTest.kt | 10 +- .../presentation/MyCoursesScreenTest.kt | 6 +- .../java/org/openedx/DashboardNavigator.kt | 17 + .../src/main/java/org/openedx/DashboardUI.kt | 49 + .../presentation/AllEnrolledCoursesAction.kt | 14 + .../AllEnrolledCoursesFragment.kt | 27 + .../presentation/AllEnrolledCoursesUIState.kt | 10 + .../presentation/AllEnrolledCoursesView.kt | 639 +++++++++++++ .../AllEnrolledCoursesViewModel.kt | 181 ++++ .../openedx/courses/presentation/CourseTab.kt | 5 + .../presentation/DashboardGalleryFragment.kt | 24 + .../DashboardGalleryScreenAction.kt | 13 + .../presentation/DashboardGalleryUIState.kt | 9 + .../presentation/DashboardGalleryView.kt | 863 ++++++++++++++++++ .../presentation/DashboardGalleryViewModel.kt | 130 +++ .../data/repository/DashboardRepository.kt | 32 +- .../dashboard/domain/CourseStatusFilter.kt | 18 + .../domain/interactor/DashboardInteractor.kt | 17 +- ...rdFragment.kt => DashboardListFragment.kt} | 23 +- ...ViewModel.kt => DashboardListViewModel.kt} | 3 +- .../dashboard/presentation/DashboardRouter.kt | 9 + .../main/java/org/openedx/learn/LearnType.kt | 9 + .../learn/presentation/LearnFragment.kt | 274 ++++++ .../learn/presentation/LearnViewModel.kt | 18 + .../main/res/drawable/dashboard_ic_book.xml | 44 + .../src/main/res/layout/fragment_learn.xml | 24 + dashboard/src/main/res/values/strings.xml | 20 +- .../presentation/DashboardViewModelTest.kt | 20 +- default_config/dev/config.yaml | 3 + default_config/prod/config.yaml | 3 + default_config/stage/config.yaml | 3 + .../presentation/program/ProgramFragment.kt | 36 +- .../topics/DiscussionTopicsViewModel.kt | 9 +- .../calendar/CalendarAccessDialogFragment.kt | 6 +- .../calendar/NewCalendarDialogFragment.kt | 4 +- 101 files changed, 3301 insertions(+), 490 deletions(-) delete mode 100644 app/src/main/java/org/openedx/app/InDevelopmentFragment.kt create mode 100644 app/src/main/res/color/bottom_nav_color.xml rename app/src/main/java/org/openedx/app/adapter/MainNavigationFragmentAdapter.kt => core/src/main/java/org/openedx/core/adapter/NavigationFragmentAdapter.kt (74%) create mode 100644 core/src/main/java/org/openedx/core/config/DashboardConfig.kt create mode 100644 core/src/main/java/org/openedx/core/data/model/CourseAssignments.kt create mode 100644 core/src/main/java/org/openedx/core/data/model/CourseStatus.kt create mode 100644 core/src/main/java/org/openedx/core/domain/model/CourseAssignments.kt create mode 100644 core/src/main/java/org/openedx/core/domain/model/CourseEnrollments.kt create mode 100644 core/src/main/java/org/openedx/core/domain/model/CourseStatus.kt create mode 100644 core/src/main/java/org/openedx/core/system/notifier/CourseOpenBlock.kt delete mode 100644 core/src/main/java/org/openedx/core/system/notifier/CourseRefresh.kt create mode 100644 core/src/main/java/org/openedx/core/system/notifier/RefreshDates.kt create mode 100644 core/src/main/java/org/openedx/core/system/notifier/RefreshDiscussions.kt delete mode 100644 core/src/main/res/drawable/core_ic_settings.xml create mode 100644 core/src/main/res/drawable/ic_core_chapter_icon.xml rename {core/src/main/java/org/openedx/core/presentation/course => course/src/main/java/org/openedx/course/presentation/container}/CourseContainerTab.kt (52%) delete mode 100644 course/src/main/res/drawable/ic_course_chapter_icon.xml create mode 100644 dashboard/src/main/java/org/openedx/DashboardNavigator.kt create mode 100644 dashboard/src/main/java/org/openedx/DashboardUI.kt create mode 100644 dashboard/src/main/java/org/openedx/courses/presentation/AllEnrolledCoursesAction.kt create mode 100644 dashboard/src/main/java/org/openedx/courses/presentation/AllEnrolledCoursesFragment.kt create mode 100644 dashboard/src/main/java/org/openedx/courses/presentation/AllEnrolledCoursesUIState.kt create mode 100644 dashboard/src/main/java/org/openedx/courses/presentation/AllEnrolledCoursesView.kt create mode 100644 dashboard/src/main/java/org/openedx/courses/presentation/AllEnrolledCoursesViewModel.kt create mode 100644 dashboard/src/main/java/org/openedx/courses/presentation/CourseTab.kt create mode 100644 dashboard/src/main/java/org/openedx/courses/presentation/DashboardGalleryFragment.kt create mode 100644 dashboard/src/main/java/org/openedx/courses/presentation/DashboardGalleryScreenAction.kt create mode 100644 dashboard/src/main/java/org/openedx/courses/presentation/DashboardGalleryUIState.kt create mode 100644 dashboard/src/main/java/org/openedx/courses/presentation/DashboardGalleryView.kt create mode 100644 dashboard/src/main/java/org/openedx/courses/presentation/DashboardGalleryViewModel.kt create mode 100644 dashboard/src/main/java/org/openedx/dashboard/domain/CourseStatusFilter.kt rename dashboard/src/main/java/org/openedx/dashboard/presentation/{DashboardFragment.kt => DashboardListFragment.kt} (97%) rename dashboard/src/main/java/org/openedx/dashboard/presentation/{DashboardViewModel.kt => DashboardListViewModel.kt} (99%) create mode 100644 dashboard/src/main/java/org/openedx/learn/LearnType.kt create mode 100644 dashboard/src/main/java/org/openedx/learn/presentation/LearnFragment.kt create mode 100644 dashboard/src/main/java/org/openedx/learn/presentation/LearnViewModel.kt create mode 100644 dashboard/src/main/res/drawable/dashboard_ic_book.xml create mode 100644 dashboard/src/main/res/layout/fragment_learn.xml diff --git a/app/src/main/java/org/openedx/app/AppRouter.kt b/app/src/main/java/org/openedx/app/AppRouter.kt index a68b550a2..17b47d11d 100644 --- a/app/src/main/java/org/openedx/app/AppRouter.kt +++ b/app/src/main/java/org/openedx/app/AppRouter.kt @@ -25,6 +25,7 @@ import org.openedx.course.presentation.unit.container.CourseUnitContainerFragmen import org.openedx.course.presentation.unit.video.VideoFullScreenFragment import org.openedx.course.presentation.unit.video.YoutubeVideoFullScreenFragment import org.openedx.course.settings.download.DownloadQueueFragment +import org.openedx.courses.presentation.AllEnrolledCoursesFragment import org.openedx.dashboard.presentation.DashboardRouter import org.openedx.discovery.presentation.DiscoveryRouter import org.openedx.discovery.presentation.NativeDiscoveryFragment @@ -123,6 +124,14 @@ class AppRouter : AuthRouter, DiscoveryRouter, DashboardRouter, CourseRouter, Di replaceFragmentWithBackStack(fm, UpgradeRequiredFragment()) } + override fun navigateToAllEnrolledCourses(fm: FragmentManager) { + replaceFragmentWithBackStack(fm, AllEnrolledCoursesFragment()) + } + + override fun getProgramFragmentInstance(): Fragment { + return ProgramFragment(myPrograms = true, isNestedFragment = true) + } + override fun navigateToCourseInfo( fm: FragmentManager, courseId: String, @@ -130,6 +139,18 @@ class AppRouter : AuthRouter, DiscoveryRouter, DashboardRouter, CourseRouter, Di ) { replaceFragmentWithBackStack(fm, CourseInfoFragment.newInstance(courseId, infoType)) } + + override fun navigateToCourseOutline( + fm: FragmentManager, + courseId: String, + courseTitle: String, + enrollmentMode: String + ) { + replaceFragmentWithBackStack( + fm, + CourseContainerFragment.newInstance(courseId, courseTitle, enrollmentMode) + ) + } //endregion //region DashboardRouter @@ -139,10 +160,12 @@ class AppRouter : AuthRouter, DiscoveryRouter, DashboardRouter, CourseRouter, Di courseId: String, courseTitle: String, enrollmentMode: String, + openTab: String, + resumeBlockId: String ) { replaceFragmentWithBackStack( fm, - CourseContainerFragment.newInstance(courseId, courseTitle, enrollmentMode) + CourseContainerFragment.newInstance(courseId, courseTitle, enrollmentMode, openTab, resumeBlockId) ) } diff --git a/app/src/main/java/org/openedx/app/InDevelopmentFragment.kt b/app/src/main/java/org/openedx/app/InDevelopmentFragment.kt deleted file mode 100644 index d8ca717d4..000000000 --- a/app/src/main/java/org/openedx/app/InDevelopmentFragment.kt +++ /dev/null @@ -1,56 +0,0 @@ -package org.openedx.app - -import android.os.Bundle -import android.view.LayoutInflater -import android.view.ViewGroup -import androidx.compose.foundation.background -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.padding -import androidx.compose.material.MaterialTheme -import androidx.compose.material.Scaffold -import androidx.compose.material.Text -import androidx.compose.ui.Alignment -import androidx.compose.ui.ExperimentalComposeUiApi -import androidx.compose.ui.Modifier -import androidx.compose.ui.platform.ComposeView -import androidx.compose.ui.platform.ViewCompositionStrategy -import androidx.compose.ui.platform.testTag -import androidx.compose.ui.semantics.semantics -import androidx.compose.ui.semantics.testTagsAsResourceId -import androidx.fragment.app.Fragment -import org.openedx.core.ui.theme.appColors -import org.openedx.core.ui.theme.appTypography - -class InDevelopmentFragment : Fragment() { - - @OptIn(ExperimentalComposeUiApi::class) - override fun onCreateView( - inflater: LayoutInflater, - container: ViewGroup?, - savedInstanceState: Bundle? - ) = ComposeView(requireContext()).apply { - setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed) - setContent { - Scaffold( - modifier = Modifier.semantics { - testTagsAsResourceId = true - }, - ) { - Box( - modifier = Modifier - .fillMaxSize() - .padding(it) - .background(MaterialTheme.appColors.secondary), - contentAlignment = Alignment.Center - ) { - Text( - modifier = Modifier.testTag("txt_in_development"), - text = "Will be available soon", - style = MaterialTheme.appTypography.headlineMedium - ) - } - } - } - } -} diff --git a/app/src/main/java/org/openedx/app/MainFragment.kt b/app/src/main/java/org/openedx/app/MainFragment.kt index a798c4a3f..fc4fb1b22 100644 --- a/app/src/main/java/org/openedx/app/MainFragment.kt +++ b/app/src/main/java/org/openedx/app/MainFragment.kt @@ -11,15 +11,13 @@ import androidx.viewpager2.widget.ViewPager2 import kotlinx.coroutines.launch import org.koin.android.ext.android.inject import org.koin.androidx.viewmodel.ext.android.viewModel -import org.openedx.app.adapter.MainNavigationFragmentAdapter +import org.openedx.DashboardNavigator import org.openedx.app.databinding.FragmentMainBinding -import org.openedx.core.config.Config +import org.openedx.core.adapter.NavigationFragmentAdapter import org.openedx.core.presentation.global.app_upgrade.UpgradeRequiredFragment import org.openedx.core.presentation.global.viewBinding -import org.openedx.dashboard.presentation.DashboardFragment import org.openedx.discovery.presentation.DiscoveryNavigator import org.openedx.discovery.presentation.DiscoveryRouter -import org.openedx.discovery.presentation.program.ProgramFragment import org.openedx.profile.presentation.profile.ProfileFragment class MainFragment : Fragment(R.layout.fragment_main) { @@ -27,9 +25,8 @@ class MainFragment : Fragment(R.layout.fragment_main) { private val binding by viewBinding(FragmentMainBinding::bind) private val viewModel by viewModel() private val router by inject() - private val config by inject() - private lateinit var adapter: MainNavigationFragmentAdapter + private lateinit var adapter: NavigationFragmentAdapter override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) @@ -47,24 +44,19 @@ class MainFragment : Fragment(R.layout.fragment_main) { binding.bottomNavView.setOnItemSelectedListener { when (it.itemId) { - R.id.fragmentHome -> { - viewModel.logDiscoveryTabClickedEvent() + R.id.fragmentLearn -> { + viewModel.logMyCoursesTabClickedEvent() binding.viewPager.setCurrentItem(0, false) } - R.id.fragmentDashboard -> { - viewModel.logMyCoursesTabClickedEvent() + R.id.fragmentDiscover -> { + viewModel.logDiscoveryTabClickedEvent() binding.viewPager.setCurrentItem(1, false) } - R.id.fragmentPrograms -> { - viewModel.logMyProgramsTabClickedEvent() - binding.viewPager.setCurrentItem(2, false) - } - R.id.fragmentProfile -> { viewModel.logProfileTabClickedEvent() - binding.viewPager.setCurrentItem(3, false) + binding.viewPager.setCurrentItem(2, false) } } true @@ -79,7 +71,7 @@ class MainFragment : Fragment(R.layout.fragment_main) { viewLifecycleOwner.lifecycleScope.launch { viewModel.navigateToDiscovery.collect { shouldNavigateToDiscovery -> if (shouldNavigateToDiscovery) { - binding.bottomNavView.selectedItemId = R.id.fragmentHome + binding.bottomNavView.selectedItemId = R.id.fragmentDiscover } } } @@ -88,7 +80,7 @@ class MainFragment : Fragment(R.layout.fragment_main) { getString(ARG_COURSE_ID).takeIf { it.isNullOrBlank().not() }?.let { courseId -> val infoType = getString(ARG_INFO_TYPE) - if (config.getDiscoveryConfig().isViewTypeWebView() && infoType != null) { + if (viewModel.isDiscoveryTypeWebView && infoType != null) { router.navigateToCourseInfo(parentFragmentManager, courseId, infoType) } else { router.navigateToCourseDetail(parentFragmentManager, courseId) @@ -105,18 +97,12 @@ class MainFragment : Fragment(R.layout.fragment_main) { binding.viewPager.orientation = ViewPager2.ORIENTATION_HORIZONTAL binding.viewPager.offscreenPageLimit = 4 - val discoveryFragment = DiscoveryNavigator(viewModel.isDiscoveryTypeWebView) - .getDiscoveryFragment() - val programFragment = if (viewModel.isProgramTypeWebView) { - ProgramFragment(true) - } else { - InDevelopmentFragment() - } + val discoveryFragment = DiscoveryNavigator(viewModel.isDiscoveryTypeWebView).getDiscoveryFragment() + val dashboardFragment = DashboardNavigator(viewModel.dashboardType).getDashboardFragment() - adapter = MainNavigationFragmentAdapter(this).apply { + adapter = NavigationFragmentAdapter(this).apply { + addFragment(dashboardFragment) addFragment(discoveryFragment) - addFragment(DashboardFragment()) - addFragment(programFragment) addFragment(ProfileFragment()) } binding.viewPager.adapter = adapter diff --git a/app/src/main/java/org/openedx/app/MainViewModel.kt b/app/src/main/java/org/openedx/app/MainViewModel.kt index 6a30533ea..eed901039 100644 --- a/app/src/main/java/org/openedx/app/MainViewModel.kt +++ b/app/src/main/java/org/openedx/app/MainViewModel.kt @@ -30,16 +30,18 @@ class MainViewModel( get() = _navigateToDiscovery.asSharedFlow() val isDiscoveryTypeWebView get() = config.getDiscoveryConfig().isViewTypeWebView() - - val isProgramTypeWebView get() = config.getProgramConfig().isViewTypeWebView() + val dashboardType get() = config.getDashboardConfig().getType() override fun onCreate(owner: LifecycleOwner) { super.onCreate(owner) - notifier.notifier.onEach { - if (it is NavigationToDiscovery) { - _navigateToDiscovery.emit(true) + notifier.notifier + .onEach { + if (it is NavigationToDiscovery) { + _navigateToDiscovery.emit(true) + } } - }.distinctUntilChanged().launchIn(viewModelScope) + .distinctUntilChanged() + .launchIn(viewModelScope) } fun enableBottomBar(enable: Boolean) { @@ -54,10 +56,6 @@ class MainViewModel( logEvent(AppAnalyticsEvent.MY_COURSES) } - fun logMyProgramsTabClickedEvent() { - logEvent(AppAnalyticsEvent.MY_PROGRAMS) - } - fun logProfileTabClickedEvent() { logEvent(AppAnalyticsEvent.PROFILE) } diff --git a/app/src/main/java/org/openedx/app/di/AppModule.kt b/app/src/main/java/org/openedx/app/di/AppModule.kt index 529f00ac0..a5ec76b37 100644 --- a/app/src/main/java/org/openedx/app/di/AppModule.kt +++ b/app/src/main/java/org/openedx/app/di/AppModule.kt @@ -48,6 +48,7 @@ import org.openedx.core.system.notifier.CourseNotifier import org.openedx.core.system.notifier.DiscoveryNotifier import org.openedx.core.system.notifier.DownloadNotifier import org.openedx.core.system.notifier.VideoNotifier +import org.openedx.core.utils.FileUtil import org.openedx.course.data.storage.CoursePreferences import org.openedx.course.presentation.CourseAnalytics import org.openedx.course.presentation.CourseRouter @@ -181,4 +182,6 @@ val appModule = module { factory { GoogleAuthHelper(get()) } factory { MicrosoftAuthHelper() } factory { OAuthHelper(get(), get(), get()) } + + factory { FileUtil(get()) } } 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 3c99dbc0f..cd3615e26 100644 --- a/app/src/main/java/org/openedx/app/di/ScreenModule.kt +++ b/app/src/main/java/org/openedx/app/di/ScreenModule.kt @@ -29,9 +29,11 @@ import org.openedx.course.presentation.unit.video.VideoUnitViewModel import org.openedx.course.presentation.unit.video.VideoViewModel import org.openedx.course.presentation.videos.CourseVideoViewModel import org.openedx.course.settings.download.DownloadQueueViewModel +import org.openedx.courses.presentation.AllEnrolledCoursesViewModel +import org.openedx.courses.presentation.DashboardGalleryViewModel import org.openedx.dashboard.data.repository.DashboardRepository import org.openedx.dashboard.domain.interactor.DashboardInteractor -import org.openedx.dashboard.presentation.DashboardViewModel +import org.openedx.dashboard.presentation.DashboardListViewModel import org.openedx.discovery.data.repository.DiscoveryRepository import org.openedx.discovery.domain.interactor.DiscoveryInteractor import org.openedx.discovery.presentation.NativeDiscoveryViewModel @@ -49,6 +51,7 @@ import org.openedx.discussion.presentation.search.DiscussionSearchThreadViewMode import org.openedx.discussion.presentation.threads.DiscussionAddThreadViewModel import org.openedx.discussion.presentation.threads.DiscussionThreadsViewModel import org.openedx.discussion.presentation.topics.DiscussionTopicsViewModel +import org.openedx.learn.presentation.LearnViewModel import org.openedx.profile.data.repository.ProfileRepository import org.openedx.profile.domain.interactor.ProfileInteractor import org.openedx.profile.domain.model.Account @@ -115,9 +118,12 @@ val screenModule = module { } viewModel { RestorePasswordViewModel(get(), get(), get(), get()) } - factory { DashboardRepository(get(), get(), get()) } + factory { DashboardRepository(get(), get(), get(), get()) } factory { DashboardInteractor(get()) } - viewModel { DashboardViewModel(get(), get(), get(), get(), get(), get(), get()) } + viewModel { DashboardListViewModel(get(), get(), get(), get(), get(), get(), get()) } + viewModel { DashboardGalleryViewModel(get(), get(), get(), get(), get(), get(), get()) } + viewModel { AllEnrolledCoursesViewModel(get(), get(), get(), get(), get(), get(), get()) } + viewModel { LearnViewModel(get(), get()) } factory { DiscoveryRepository(get(), get(), get()) } factory { DiscoveryInteractor(get()) } @@ -194,10 +200,11 @@ val screenModule = module { get() ) } - viewModel { (courseId: String, courseTitle: String, enrollmentMode: String) -> + viewModel { (courseId: String, courseTitle: String, enrollmentMode: String, resumeBlockId: String) -> CourseContainerViewModel( courseId, courseTitle, + resumeBlockId, enrollmentMode, get(), get(), @@ -226,6 +233,7 @@ val screenModule = module { get(), get(), get(), + get() ) } viewModel { (courseId: String) -> @@ -267,6 +275,7 @@ val screenModule = module { get(), get(), get(), + get() ) } viewModel { (courseId: String) -> BaseVideoViewModel(courseId, get()) } @@ -306,6 +315,7 @@ val screenModule = module { get(), get(), get(), + get() ) } viewModel { (courseId: String, handoutsType: String) -> diff --git a/app/src/main/res/color/bottom_nav_color.xml b/app/src/main/res/color/bottom_nav_color.xml new file mode 100644 index 000000000..4e2851e90 --- /dev/null +++ b/app/src/main/res/color/bottom_nav_color.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/app/src/main/res/drawable/app_ic_rows.xml b/app/src/main/res/drawable/app_ic_rows.xml index 41b74e9b4..eabe550d3 100644 --- a/app/src/main/res/drawable/app_ic_rows.xml +++ b/app/src/main/res/drawable/app_ic_rows.xml @@ -1,38 +1,10 @@ - - - - - - - + android:width="20dp" + android:height="17dp" + android:viewportWidth="20" + android:viewportHeight="17"> + diff --git a/app/src/main/res/layout/fragment_main.xml b/app/src/main/res/layout/fragment_main.xml index 89cf2914a..9794b7bd7 100644 --- a/app/src/main/res/layout/fragment_main.xml +++ b/app/src/main/res/layout/fragment_main.xml @@ -19,6 +19,8 @@ android:layout_width="match_parent" android:layout_height="wrap_content" android:background="@color/background" + app:itemIconTint="@color/bottom_nav_color" + app:itemTextColor="@color/bottom_nav_color" app:labelVisibilityMode="labeled" app:layout_constraintBottom_toBottomOf="parent" app:layout_constraintEnd_toEndOf="parent" diff --git a/app/src/main/res/menu/bottom_view_menu.xml b/app/src/main/res/menu/bottom_view_menu.xml index 60ba4f78c..f97e849f7 100644 --- a/app/src/main/res/menu/bottom_view_menu.xml +++ b/app/src/main/res/menu/bottom_view_menu.xml @@ -2,27 +2,21 @@ + android:icon="@drawable/app_ic_rows" + android:title="@string/app_navigation_learn" /> - - + android:icon="@drawable/app_ic_home" + android:title="@string/app_navigation_discovery" /> + android:icon="@drawable/app_ic_profile" + android:title="@string/app_navigation_profile" /> - \ No newline at end of file + diff --git a/app/src/main/res/values-uk/strings.xml b/app/src/main/res/values-uk/strings.xml index 8e4178d90..17d58ded3 100644 --- a/app/src/main/res/values-uk/strings.xml +++ b/app/src/main/res/values-uk/strings.xml @@ -5,7 +5,7 @@ Назад Всі курси - Мої курси + Мої курси Програми Профіль - \ No newline at end of file + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index f24815f30..baa1c2a89 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -4,7 +4,7 @@ Previous Discover - Dashboard + Learn Programs Profile - \ No newline at end of file + diff --git a/app/src/main/java/org/openedx/app/adapter/MainNavigationFragmentAdapter.kt b/core/src/main/java/org/openedx/core/adapter/NavigationFragmentAdapter.kt similarity index 74% rename from app/src/main/java/org/openedx/app/adapter/MainNavigationFragmentAdapter.kt rename to core/src/main/java/org/openedx/core/adapter/NavigationFragmentAdapter.kt index ccbe6f715..273c53427 100644 --- a/app/src/main/java/org/openedx/app/adapter/MainNavigationFragmentAdapter.kt +++ b/core/src/main/java/org/openedx/core/adapter/NavigationFragmentAdapter.kt @@ -1,9 +1,9 @@ -package org.openedx.app.adapter +package org.openedx.core.adapter import androidx.fragment.app.Fragment import androidx.viewpager2.adapter.FragmentStateAdapter -class MainNavigationFragmentAdapter(fragment: Fragment) : FragmentStateAdapter(fragment) { +class NavigationFragmentAdapter(fragment: Fragment) : FragmentStateAdapter(fragment) { private val fragments = ArrayList() diff --git a/core/src/main/java/org/openedx/core/config/Config.kt b/core/src/main/java/org/openedx/core/config/Config.kt index 4d71b8dc1..c2fb7c15d 100644 --- a/core/src/main/java/org/openedx/core/config/Config.kt +++ b/core/src/main/java/org/openedx/core/config/Config.kt @@ -95,6 +95,10 @@ class Config(context: Context) { return getObjectOrNewInstance(PROGRAM, ProgramConfig::class.java) } + fun getDashboardConfig(): DashboardConfig { + return getObjectOrNewInstance(DASHBOARD, DashboardConfig::class.java) + } + fun getBranchConfig(): BranchConfig { return getObjectOrNewInstance(BRANCH, BranchConfig::class.java) } @@ -169,6 +173,7 @@ class Config(context: Context) { private const val PRE_LOGIN_EXPERIENCE_ENABLED = "PRE_LOGIN_EXPERIENCE_ENABLED" private const val DISCOVERY = "DISCOVERY" private const val PROGRAM = "PROGRAM" + private const val DASHBOARD = "DASHBOARD" private const val BRANCH = "BRANCH" private const val COURSE_DROPDOWN_NAVIGATION_ENABLED = "COURSE_DROPDOWN_NAVIGATION_ENABLED" private const val COURSE_UNIT_PROGRESS_ENABLED = "COURSE_UNIT_PROGRESS_ENABLED" diff --git a/core/src/main/java/org/openedx/core/config/DashboardConfig.kt b/core/src/main/java/org/openedx/core/config/DashboardConfig.kt new file mode 100644 index 000000000..9aa081aff --- /dev/null +++ b/core/src/main/java/org/openedx/core/config/DashboardConfig.kt @@ -0,0 +1,16 @@ +package org.openedx.core.config + +import com.google.gson.annotations.SerializedName + +data class DashboardConfig( + @SerializedName("TYPE") + private val viewType: String = DashboardType.GALLERY.name, +) { + fun getType(): DashboardType { + return DashboardType.valueOf(viewType.uppercase()) + } + + enum class DashboardType { + LIST, GALLERY + } +} diff --git a/core/src/main/java/org/openedx/core/data/api/CourseApi.kt b/core/src/main/java/org/openedx/core/data/api/CourseApi.kt index 4a19c383d..6d30a9044 100644 --- a/core/src/main/java/org/openedx/core/data/api/CourseApi.kt +++ b/core/src/main/java/org/openedx/core/data/api/CourseApi.kt @@ -67,4 +67,13 @@ interface CourseApi { @GET("/api/mobile/v1/course_info/{course_id}/updates") suspend fun getAnnouncements(@Path("course_id") courseId: String): List + + @GET("/api/mobile/v4/users/{username}/course_enrollments/") + suspend fun getUserCourses( + @Path("username") username: String, + @Query("page") page: Int = 1, + @Query("page_size") pageSize: Int = 20, + @Query("status") status: String? = null, + @Query("requested_fields") fields: List = emptyList() + ): CourseEnrollments } diff --git a/core/src/main/java/org/openedx/core/data/model/CourseAssignments.kt b/core/src/main/java/org/openedx/core/data/model/CourseAssignments.kt new file mode 100644 index 000000000..ed8de3a4e --- /dev/null +++ b/core/src/main/java/org/openedx/core/data/model/CourseAssignments.kt @@ -0,0 +1,30 @@ +package org.openedx.core.data.model + +import com.google.gson.annotations.SerializedName +import org.openedx.core.data.model.room.discovery.CourseAssignmentsDb +import org.openedx.core.domain.model.CourseAssignments + +data class CourseAssignments( + @SerializedName("future_assignments") + val futureAssignments: List?, + @SerializedName("past_assignments") + val pastAssignments: List?, +) { + fun mapToDomain() = CourseAssignments( + futureAssignments = futureAssignments?.mapNotNull { + it.mapToDomain() + }, + pastAssignments = pastAssignments?.mapNotNull { + it.mapToDomain() + } + ) + + fun mapToRoomEntity() = CourseAssignmentsDb( + futureAssignments = futureAssignments?.mapNotNull { + it.mapToRoomEntity() + }, + pastAssignments = pastAssignments?.mapNotNull { + it.mapToRoomEntity() + } + ) +} diff --git a/core/src/main/java/org/openedx/core/data/model/CourseDateBlock.kt b/core/src/main/java/org/openedx/core/data/model/CourseDateBlock.kt index 887112845..d29e7a7ea 100644 --- a/core/src/main/java/org/openedx/core/data/model/CourseDateBlock.kt +++ b/core/src/main/java/org/openedx/core/data/model/CourseDateBlock.kt @@ -1,8 +1,13 @@ package org.openedx.core.data.model +import android.os.Parcelable import com.google.gson.annotations.SerializedName -import java.util.* +import kotlinx.parcelize.Parcelize +import org.openedx.core.data.model.room.discovery.CourseDateBlockDb +import org.openedx.core.domain.model.CourseDateBlock +import org.openedx.core.utils.TimeUtils +@Parcelize data class CourseDateBlock( @SerializedName("complete") val complete: Boolean = false, @@ -25,4 +30,36 @@ data class CourseDateBlock( // component blockId in-case of navigating inside the app for component available in mobile @SerializedName("first_component_block_id") val blockId: String = "", -) +) : Parcelable { + fun mapToDomain(): CourseDateBlock? { + TimeUtils.iso8601ToDate(date)?.let { + return CourseDateBlock( + complete = complete, + date = it, + assignmentType = assignmentType, + dateType = dateType, + description = description, + learnerHasAccess = learnerHasAccess, + link = link, + title = title, + blockId = blockId + ) + } ?: return null + } + + fun mapToRoomEntity(): CourseDateBlockDb? { + TimeUtils.iso8601ToDate(date)?.let { + return CourseDateBlockDb( + complete = complete, + date = it, + assignmentType = assignmentType, + dateType = dateType, + description = description, + learnerHasAccess = learnerHasAccess, + link = link, + title = title, + blockId = blockId + ) + } ?: return null + } +} diff --git a/core/src/main/java/org/openedx/core/data/model/CourseEnrollments.kt b/core/src/main/java/org/openedx/core/data/model/CourseEnrollments.kt index 89ecdcab4..ca28740fe 100644 --- a/core/src/main/java/org/openedx/core/data/model/CourseEnrollments.kt +++ b/core/src/main/java/org/openedx/core/data/model/CourseEnrollments.kt @@ -7,6 +7,7 @@ import com.google.gson.JsonElement import com.google.gson.JsonObject import com.google.gson.annotations.SerializedName import java.lang.reflect.Type +import org.openedx.core.domain.model.CourseEnrollments as DomainCourseEnrollments data class CourseEnrollments( @SerializedName("enrollments") @@ -14,17 +15,38 @@ data class CourseEnrollments( @SerializedName("config") val configs: AppConfig, + + @SerializedName("primary") + val primary: EnrolledCourse?, ) { + fun mapToDomain() = DomainCourseEnrollments( + enrollments = enrollments.mapToDomain(), + configs = configs.mapToDomain(), + primary = primary?.mapToDomain() + ) + class Deserializer : JsonDeserializer { override fun deserialize( json: JsonElement?, typeOfT: Type?, - context: JsonDeserializationContext? + context: JsonDeserializationContext?, ): CourseEnrollments { val enrollments = deserializeEnrollments(json) val appConfig = deserializeAppConfig(json) + val primaryCourse = deserializePrimaryCourse(json) - return CourseEnrollments(enrollments, appConfig) + return CourseEnrollments(enrollments, appConfig, primaryCourse) + } + + private fun deserializePrimaryCourse(json: JsonElement?): EnrolledCourse? { + return try { + Gson().fromJson( + (json as JsonObject).get("primary"), + EnrolledCourse::class.java + ) + } catch (ex: Exception) { + null + } } private fun deserializeEnrollments(json: JsonElement?): DashboardCourseList { diff --git a/core/src/main/java/org/openedx/core/data/model/CourseStatus.kt b/core/src/main/java/org/openedx/core/data/model/CourseStatus.kt new file mode 100644 index 000000000..53cb028b4 --- /dev/null +++ b/core/src/main/java/org/openedx/core/data/model/CourseStatus.kt @@ -0,0 +1,30 @@ +package org.openedx.core.data.model + +import com.google.gson.annotations.SerializedName +import org.openedx.core.data.model.room.discovery.CourseStatusDb +import org.openedx.core.domain.model.CourseStatus + +data class CourseStatus( + @SerializedName("last_visited_module_id") + val lastVisitedModuleId: String?, + @SerializedName("last_visited_module_path") + val lastVisitedModulePath: List?, + @SerializedName("last_visited_block_id") + val lastVisitedBlockId: String?, + @SerializedName("last_visited_unit_display_name") + val lastVisitedUnitDisplayName: String?, +) { + fun mapToDomain() = CourseStatus( + lastVisitedModuleId = lastVisitedModuleId ?: "", + lastVisitedModulePath = lastVisitedModulePath ?: emptyList(), + lastVisitedBlockId = lastVisitedBlockId ?: "", + lastVisitedUnitDisplayName = lastVisitedUnitDisplayName ?: "" + ) + + fun mapToRoomEntity() = CourseStatusDb( + lastVisitedModuleId = lastVisitedModuleId ?: "", + lastVisitedModulePath = lastVisitedModulePath ?: emptyList(), + lastVisitedBlockId = lastVisitedBlockId ?: "", + lastVisitedUnitDisplayName = lastVisitedUnitDisplayName ?: "" + ) +} diff --git a/core/src/main/java/org/openedx/core/data/model/EnrolledCourse.kt b/core/src/main/java/org/openedx/core/data/model/EnrolledCourse.kt index 984794698..edf8bbce3 100644 --- a/core/src/main/java/org/openedx/core/data/model/EnrolledCourse.kt +++ b/core/src/main/java/org/openedx/core/data/model/EnrolledCourse.kt @@ -2,8 +2,10 @@ package org.openedx.core.data.model import com.google.gson.annotations.SerializedName import org.openedx.core.data.model.room.discovery.EnrolledCourseEntity +import org.openedx.core.data.model.room.discovery.ProgressDb import org.openedx.core.domain.model.EnrolledCourse import org.openedx.core.utils.TimeUtils +import org.openedx.core.domain.model.Progress as ProgressDomain data class EnrolledCourse( @SerializedName("audit_access_expires") @@ -17,7 +19,13 @@ data class EnrolledCourse( @SerializedName("course") val course: EnrolledCourseData?, @SerializedName("certificate") - val certificate: Certificate? + val certificate: Certificate?, + @SerializedName("course_progress") + val progress: Progress?, + @SerializedName("course_status") + val courseStatus: CourseStatus?, + @SerializedName("course_assignments") + val courseAssignments: CourseAssignments? ) { fun mapToDomain(): EnrolledCourse { return EnrolledCourse( @@ -26,7 +34,10 @@ data class EnrolledCourse( mode = mode ?: "", isActive = isActive ?: false, course = course?.mapToDomain()!!, - certificate = certificate?.mapToDomain() + certificate = certificate?.mapToDomain(), + progress = progress?.mapToDomain() ?: ProgressDomain.DEFAULT_PROGRESS, + courseStatus = courseStatus?.mapToDomain(), + courseAssignments = courseAssignments?.mapToDomain() ) } @@ -38,7 +49,10 @@ data class EnrolledCourse( mode = mode ?: "", isActive = isActive ?: false, course = course?.mapToRoomEntity()!!, - certificate = certificate?.mapToRoomEntity() + certificate = certificate?.mapToRoomEntity(), + progress = progress?.mapToRoomEntity() ?: ProgressDb.DEFAULT_PROGRESS, + courseStatus = courseStatus?.mapToRoomEntity(), + courseAssignments = courseAssignments?.mapToRoomEntity() ) } } diff --git a/core/src/main/java/org/openedx/core/data/model/Progress.kt b/core/src/main/java/org/openedx/core/data/model/Progress.kt index 057148ea9..d4813c14c 100644 --- a/core/src/main/java/org/openedx/core/data/model/Progress.kt +++ b/core/src/main/java/org/openedx/core/data/model/Progress.kt @@ -2,22 +2,21 @@ package org.openedx.core.data.model import com.google.gson.annotations.SerializedName import org.openedx.core.data.model.room.discovery.ProgressDb +import org.openedx.core.domain.model.Progress data class Progress( @SerializedName("assignments_completed") val assignmentsCompleted: Int?, @SerializedName("total_assignments_count") - val totalAssignmentsCount: Int? + val totalAssignmentsCount: Int?, ) { - fun mapToDomain(): org.openedx.core.domain.model.Progress { - return org.openedx.core.domain.model.Progress( - assignmentsCompleted = assignmentsCompleted ?: 0, - totalAssignmentsCount = totalAssignmentsCount ?: 0 - ) - } + fun mapToDomain() = Progress( + assignmentsCompleted = assignmentsCompleted ?: 0, + totalAssignmentsCount = totalAssignmentsCount ?: 0 + ) fun mapToRoomEntity() = ProgressDb( assignmentsCompleted = assignmentsCompleted ?: 0, totalAssignmentsCount = totalAssignmentsCount ?: 0 ) -} \ No newline at end of file +} diff --git a/core/src/main/java/org/openedx/core/data/model/room/discovery/EnrolledCourseEntity.kt b/core/src/main/java/org/openedx/core/data/model/room/discovery/EnrolledCourseEntity.kt index 87cc68e49..00c0c37e9 100644 --- a/core/src/main/java/org/openedx/core/data/model/room/discovery/EnrolledCourseEntity.kt +++ b/core/src/main/java/org/openedx/core/data/model/room/discovery/EnrolledCourseEntity.kt @@ -4,6 +4,7 @@ import androidx.room.ColumnInfo import androidx.room.Embedded import androidx.room.Entity import androidx.room.PrimaryKey +import org.openedx.core.data.model.DateType import org.openedx.core.data.model.room.MediaDb import org.openedx.core.domain.model.Certificate import org.openedx.core.domain.model.CourseSharingUtmParameters @@ -12,6 +13,7 @@ import org.openedx.core.domain.model.EnrolledCourse import org.openedx.core.domain.model.EnrolledCourseData import org.openedx.core.domain.model.Progress import org.openedx.core.utils.TimeUtils +import java.util.Date @Entity(tableName = "course_enrolled_table") data class EnrolledCourseEntity( @@ -30,6 +32,12 @@ data class EnrolledCourseEntity( val course: EnrolledCourseDataDb, @Embedded val certificate: CertificateDb?, + @Embedded + val progress: ProgressDb, + @Embedded + val courseStatus: CourseStatusDb?, + @Embedded + val courseAssignments: CourseAssignmentsDb?, ) { fun mapToDomain(): EnrolledCourse { @@ -39,7 +47,10 @@ data class EnrolledCourseEntity( mode, isActive, course.mapToDomain(), - certificate?.mapToDomain() + certificate?.mapToDomain(), + progress.mapToDomain(), + courseStatus?.mapToDomain(), + courseAssignments?.mapToDomain() ) } } @@ -84,7 +95,7 @@ data class EnrolledCourseDataDb( @ColumnInfo("videoOutline") val videoOutline: String, @ColumnInfo("isSelfPaced") - val isSelfPaced: Boolean + val isSelfPaced: Boolean, ) { fun mapToDomain(): EnrolledCourseData { return EnrolledCourseData( @@ -124,7 +135,7 @@ data class CoursewareAccessDb( @ColumnInfo("additionalContextUserMessage") val additionalContextUserMessage: String, @ColumnInfo("userFragment") - val userFragment: String + val userFragment: String, ) { fun mapToDomain(): CoursewareAccess { @@ -140,6 +151,24 @@ data class CoursewareAccessDb( } +data class CertificateDb( + @ColumnInfo("certificateURL") + val certificateURL: String?, +) { + fun mapToDomain() = Certificate(certificateURL) +} + +data class CourseSharingUtmParametersDb( + @ColumnInfo("facebook") + val facebook: String, + @ColumnInfo("twitter") + val twitter: String, +) { + fun mapToDomain() = CourseSharingUtmParameters( + facebook, twitter + ) +} + data class ProgressDb( @ColumnInfo("assignments_completed") val assignmentsCompleted: Int, @@ -153,20 +182,62 @@ data class ProgressDb( fun mapToDomain() = Progress(assignmentsCompleted, totalAssignmentsCount) } -data class CertificateDb( - @ColumnInfo("certificateURL") - val certificateURL: String? +data class CourseStatusDb( + @ColumnInfo("lastVisitedModuleId") + val lastVisitedModuleId: String, + @ColumnInfo("lastVisitedModulePath") + val lastVisitedModulePath: List, + @ColumnInfo("lastVisitedBlockId") + val lastVisitedBlockId: String, + @ColumnInfo("lastVisitedUnitDisplayName") + val lastVisitedUnitDisplayName: String, ) { - fun mapToDomain() = Certificate(certificateURL) + fun mapToDomain() = CourseStatus( + lastVisitedModuleId, lastVisitedModulePath, lastVisitedBlockId, lastVisitedUnitDisplayName + ) } -data class CourseSharingUtmParametersDb( - @ColumnInfo("facebook") - val facebook: String, - @ColumnInfo("twitter") - val twitter: String +data class CourseAssignmentsDb( + @ColumnInfo("futureAssignments") + val futureAssignments: List?, + @ColumnInfo("pastAssignments") + val pastAssignments: List?, ) { - fun mapToDomain() = CourseSharingUtmParameters( - facebook, twitter + fun mapToDomain() = CourseAssignments( + futureAssignments = futureAssignments?.map { it.mapToDomain() }, + pastAssignments = pastAssignments?.map { it.mapToDomain() } + ) +} + +data class CourseDateBlockDb( + @ColumnInfo("title") + val title: String = "", + @ColumnInfo("description") + val description: String = "", + @ColumnInfo("link") + val link: String = "", + @ColumnInfo("blockId") + val blockId: String = "", + @ColumnInfo("learnerHasAccess") + val learnerHasAccess: Boolean = false, + @ColumnInfo("complete") + val complete: Boolean = false, + @Embedded + val date: Date, + @ColumnInfo("dateType") + val dateType: DateType = DateType.NONE, + @ColumnInfo("assignmentType") + val assignmentType: String? = "", +) { + fun mapToDomain() = CourseDateBlock( + title = title, + description = description, + link = link, + blockId = blockId, + learnerHasAccess = learnerHasAccess, + complete = complete, + date = date, + dateType = dateType, + assignmentType = assignmentType ) -} \ No newline at end of file +} diff --git a/core/src/main/java/org/openedx/core/domain/model/CourseAssignments.kt b/core/src/main/java/org/openedx/core/domain/model/CourseAssignments.kt new file mode 100644 index 000000000..feb039fc7 --- /dev/null +++ b/core/src/main/java/org/openedx/core/domain/model/CourseAssignments.kt @@ -0,0 +1,10 @@ +package org.openedx.core.domain.model + +import android.os.Parcelable +import kotlinx.parcelize.Parcelize + +@Parcelize +data class CourseAssignments( + val futureAssignments: List?, + val pastAssignments: List? +): Parcelable diff --git a/core/src/main/java/org/openedx/core/domain/model/CourseDateBlock.kt b/core/src/main/java/org/openedx/core/domain/model/CourseDateBlock.kt index 7e91c59fa..394ebdd56 100644 --- a/core/src/main/java/org/openedx/core/domain/model/CourseDateBlock.kt +++ b/core/src/main/java/org/openedx/core/domain/model/CourseDateBlock.kt @@ -1,10 +1,13 @@ package org.openedx.core.domain.model +import android.os.Parcelable +import kotlinx.parcelize.Parcelize import org.openedx.core.data.model.DateType import org.openedx.core.utils.isTimeLessThan24Hours import org.openedx.core.utils.isToday import java.util.Date +@Parcelize data class CourseDateBlock( val title: String = "", val description: String = "", @@ -15,7 +18,7 @@ data class CourseDateBlock( val date: Date, val dateType: DateType = DateType.NONE, val assignmentType: String? = "", -) { +) : Parcelable { fun isCompleted(): Boolean { return complete || (dateType in setOf( DateType.COURSE_START_DATE, diff --git a/core/src/main/java/org/openedx/core/domain/model/CourseEnrollments.kt b/core/src/main/java/org/openedx/core/domain/model/CourseEnrollments.kt new file mode 100644 index 000000000..6606902c2 --- /dev/null +++ b/core/src/main/java/org/openedx/core/domain/model/CourseEnrollments.kt @@ -0,0 +1,7 @@ +package org.openedx.core.domain.model + +data class CourseEnrollments( + val enrollments: DashboardCourseList, + val configs: AppConfig, + val primary: EnrolledCourse?, +) diff --git a/core/src/main/java/org/openedx/core/domain/model/CourseStatus.kt b/core/src/main/java/org/openedx/core/domain/model/CourseStatus.kt new file mode 100644 index 000000000..aef245f67 --- /dev/null +++ b/core/src/main/java/org/openedx/core/domain/model/CourseStatus.kt @@ -0,0 +1,12 @@ +package org.openedx.core.domain.model + +import android.os.Parcelable +import kotlinx.parcelize.Parcelize + +@Parcelize +data class CourseStatus( + val lastVisitedModuleId: String, + val lastVisitedModulePath: List, + val lastVisitedBlockId: String, + val lastVisitedUnitDisplayName: String, +) : Parcelable diff --git a/core/src/main/java/org/openedx/core/domain/model/EnrolledCourse.kt b/core/src/main/java/org/openedx/core/domain/model/EnrolledCourse.kt index 8e339b3f6..184fc3aa4 100644 --- a/core/src/main/java/org/openedx/core/domain/model/EnrolledCourse.kt +++ b/core/src/main/java/org/openedx/core/domain/model/EnrolledCourse.kt @@ -12,4 +12,7 @@ data class EnrolledCourse( val isActive: Boolean, val course: EnrolledCourseData, val certificate: Certificate?, + val progress: Progress, + val courseStatus: CourseStatus?, + val courseAssignments: CourseAssignments? ) : Parcelable diff --git a/core/src/main/java/org/openedx/core/domain/model/Progress.kt b/core/src/main/java/org/openedx/core/domain/model/Progress.kt index 8362a8133..12363051a 100644 --- a/core/src/main/java/org/openedx/core/domain/model/Progress.kt +++ b/core/src/main/java/org/openedx/core/domain/model/Progress.kt @@ -8,9 +8,14 @@ data class Progress( val assignmentsCompleted: Int, val totalAssignmentsCount: Int, ) : Parcelable { + fun getProgress(): Float = try { assignmentsCompleted.toFloat() / totalAssignmentsCount.toFloat() } catch (_: ArithmeticException) { 0f } -} \ No newline at end of file + + companion object { + val DEFAULT_PROGRESS = Progress(0, 0) + } +} diff --git a/core/src/main/java/org/openedx/core/module/DownloadWorker.kt b/core/src/main/java/org/openedx/core/module/DownloadWorker.kt index 9234ec023..736a1b1ce 100644 --- a/core/src/main/java/org/openedx/core/module/DownloadWorker.kt +++ b/core/src/main/java/org/openedx/core/module/DownloadWorker.kt @@ -23,11 +23,12 @@ import org.openedx.core.module.download.CurrentProgress import org.openedx.core.module.download.FileDownloader import org.openedx.core.system.notifier.DownloadNotifier import org.openedx.core.system.notifier.DownloadProgressChanged +import org.openedx.core.utils.FileUtil import java.io.File class DownloadWorker( val context: Context, - parameters: WorkerParameters + parameters: WorkerParameters, ) : CoroutineWorker(context, parameters), CoroutineScope { private val notificationManager = @@ -41,11 +42,7 @@ class DownloadWorker( private var downloadEnqueue = listOf() - private val folder = File( - context.externalCacheDir.toString() + File.separator + - context.getString(R.string.app_name) - .replace(Regex("\\s"), "_") - ) + private val folder = FileUtil(context).getExternalAppDir() private var currentDownload: DownloadModel? = null private var lastUpdateTime = 0L diff --git a/core/src/main/java/org/openedx/core/module/TranscriptManager.kt b/core/src/main/java/org/openedx/core/module/TranscriptManager.kt index 863586900..c08870a33 100644 --- a/core/src/main/java/org/openedx/core/module/TranscriptManager.kt +++ b/core/src/main/java/org/openedx/core/module/TranscriptManager.kt @@ -1,9 +1,12 @@ package org.openedx.core.module import android.content.Context -import org.openedx.core.module.download.AbstractDownloader -import org.openedx.core.utils.* import okhttp3.OkHttpClient +import org.openedx.core.module.download.AbstractDownloader +import org.openedx.core.utils.Directories +import org.openedx.core.utils.FileUtil +import org.openedx.core.utils.IOUtils +import org.openedx.core.utils.Sha1Util import subtitleFile.FormatSRT import subtitleFile.TimedTextObject import java.io.File @@ -14,7 +17,7 @@ import java.nio.charset.Charset import java.util.concurrent.TimeUnit class TranscriptManager( - val context: Context + val context: Context, ) { private val transcriptDownloader = object : AbstractDownloader() { @@ -28,7 +31,9 @@ class TranscriptManager( val transcriptDir = getTranscriptDir() ?: return false val hash = Sha1Util.SHA1(url) val file = File(transcriptDir, hash) - return file.exists() && System.currentTimeMillis() - file.lastModified() < TimeUnit.HOURS.toMillis(5) + return file.exists() && System.currentTimeMillis() - file.lastModified() < TimeUnit.HOURS.toMillis( + 5 + ) } fun get(url: String): String? { @@ -113,7 +118,7 @@ class TranscriptManager( } private fun getTranscriptDir(): File? { - val externalAppDir: File = FileUtil.getExternalAppDir(context) + val externalAppDir: File = FileUtil(context).getExternalAppDir() if (externalAppDir.exists()) { val videosDir = File(externalAppDir, Directories.VIDEOS.name) val transcriptDir = File(videosDir, Directories.SUBTITLES.name) @@ -122,5 +127,4 @@ class TranscriptManager( } return null } - -} \ No newline at end of file +} 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 f4908bdef..527a7ce51 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 @@ -18,5 +18,7 @@ class CourseNotifier { suspend fun send(event: CalendarSyncEvent) = channel.emit(event) suspend fun send(event: CourseDatesShifted) = channel.emit(event) suspend fun send(event: CourseLoading) = channel.emit(event) - suspend fun send(event: CourseRefresh) = channel.emit(event) + suspend fun send(event: CourseOpenBlock) = channel.emit(event) + suspend fun send(event: RefreshDates) = channel.emit(event) + suspend fun send(event: RefreshDiscussions) = channel.emit(event) } diff --git a/core/src/main/java/org/openedx/core/system/notifier/CourseOpenBlock.kt b/core/src/main/java/org/openedx/core/system/notifier/CourseOpenBlock.kt new file mode 100644 index 000000000..6704f1256 --- /dev/null +++ b/core/src/main/java/org/openedx/core/system/notifier/CourseOpenBlock.kt @@ -0,0 +1,3 @@ +package org.openedx.core.system.notifier + +data class CourseOpenBlock(val blockId: String) : CourseEvent diff --git a/core/src/main/java/org/openedx/core/system/notifier/CourseRefresh.kt b/core/src/main/java/org/openedx/core/system/notifier/CourseRefresh.kt deleted file mode 100644 index c85fc595d..000000000 --- a/core/src/main/java/org/openedx/core/system/notifier/CourseRefresh.kt +++ /dev/null @@ -1,5 +0,0 @@ -package org.openedx.core.system.notifier - -import org.openedx.core.presentation.course.CourseContainerTab - -data class CourseRefresh(val courseContainerTab: CourseContainerTab) : CourseEvent diff --git a/core/src/main/java/org/openedx/core/system/notifier/RefreshDates.kt b/core/src/main/java/org/openedx/core/system/notifier/RefreshDates.kt new file mode 100644 index 000000000..779d1b924 --- /dev/null +++ b/core/src/main/java/org/openedx/core/system/notifier/RefreshDates.kt @@ -0,0 +1,3 @@ +package org.openedx.core.system.notifier + +object RefreshDates : CourseEvent diff --git a/core/src/main/java/org/openedx/core/system/notifier/RefreshDiscussions.kt b/core/src/main/java/org/openedx/core/system/notifier/RefreshDiscussions.kt new file mode 100644 index 000000000..5c51f605b --- /dev/null +++ b/core/src/main/java/org/openedx/core/system/notifier/RefreshDiscussions.kt @@ -0,0 +1,3 @@ +package org.openedx.core.system.notifier + +object RefreshDiscussions : CourseEvent diff --git a/core/src/main/java/org/openedx/core/ui/ComposeCommon.kt b/core/src/main/java/org/openedx/core/ui/ComposeCommon.kt index 1692e7a4d..6c57df741 100644 --- a/core/src/main/java/org/openedx/core/ui/ComposeCommon.kt +++ b/core/src/main/java/org/openedx/core/ui/ComposeCommon.kt @@ -28,6 +28,7 @@ import androidx.compose.foundation.lazy.LazyRow import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.pager.HorizontalPager import androidx.compose.foundation.pager.PagerState import androidx.compose.foundation.pager.rememberPagerState import androidx.compose.foundation.shape.CircleShape @@ -48,6 +49,7 @@ import androidx.compose.material.TextFieldDefaults import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.AccountCircle import androidx.compose.material.icons.filled.Close +import androidx.compose.material.icons.filled.ManageAccounts import androidx.compose.material.icons.filled.Search import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect @@ -199,8 +201,8 @@ fun Toolbar( onClick = { onSettingsClick() } ) { Icon( - painter = painterResource(id = R.drawable.core_ic_settings), - tint = MaterialTheme.appColors.primary, + imageVector = Icons.Default.ManageAccounts, + tint = MaterialTheme.appColors.textAccent, contentDescription = stringResource(id = R.string.core_accessibility_settings) ) } @@ -939,22 +941,23 @@ fun TextIcon( icon: ImageVector, color: Color, textStyle: TextStyle = MaterialTheme.appTypography.bodySmall, - iconModifier: Modifier = Modifier, + modifier: Modifier = Modifier, + iconModifier: Modifier? = null, onClick: (() -> Unit)? = null, ) { - val modifier = if (onClick == null) { - Modifier + val rowModifier = if (onClick == null) { + modifier } else { - Modifier.noRippleClickable { onClick.invoke() } + modifier.clickable { onClick.invoke() } } Row( - modifier = modifier, + modifier = rowModifier, verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(4.dp) ) { Text(text = text, color = color, style = textStyle) Icon( - modifier = iconModifier.size((textStyle.fontSize.value + 4).dp), + modifier = iconModifier ?: Modifier.size((textStyle.fontSize.value + 4).dp), imageVector = icon, contentDescription = null, tint = color @@ -1213,17 +1216,22 @@ fun RoundTabsBar( modifier: Modifier = Modifier, items: List, pagerState: PagerState, + contentPadding: PaddingValues = PaddingValues(), + withPager: Boolean = false, rowState: LazyListState = rememberLazyListState(), - onPageChange: (Int) -> Unit + onTabClicked: (Int) -> Unit = { } ) { + // The pager state does not work without the pager and the tabs do not change. + if (!withPager) { + HorizontalPager(state = pagerState) { } + } + val scope = rememberCoroutineScope() - val windowSize = rememberWindowSize() - val horizontalPadding = if (!windowSize.isTablet) 12.dp else 98.dp LazyRow( modifier = modifier, state = rowState, horizontalArrangement = Arrangement.spacedBy(8.dp), - contentPadding = PaddingValues(vertical = 16.dp, horizontal = horizontalPadding), + contentPadding = contentPadding, ) { itemsIndexed(items) { index, item -> val isSelected = pagerState.currentPage == index @@ -1246,10 +1254,11 @@ fun RoundTabsBar( .clickable { scope.launch { pagerState.scrollToPage(index) - onPageChange(index) + rowState.animateScrollToItem(index) + onTabClicked(index) } } - .padding(horizontal = 12.dp), + .padding(horizontal = 16.dp), item = item, contentColor = contentColor ) @@ -1268,12 +1277,15 @@ private fun RoundTab( verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.Center ) { - Icon( - painter = rememberVectorPainter(item.icon), - tint = contentColor, - contentDescription = null - ) - Spacer(modifier = Modifier.width(4.dp)) + val icon = item.icon + if (icon != null) { + Icon( + painter = rememberVectorPainter(icon), + tint = contentColor, + contentDescription = null + ) + Spacer(modifier = Modifier.width(4.dp)) + } Text( text = stringResource(item.labelResId), color = contentColor @@ -1374,7 +1386,7 @@ private fun RoundTabsBarPreview() { items = listOf(mockTab, mockTab, mockTab), rowState = rememberLazyListState(), pagerState = rememberPagerState(pageCount = { 3 }), - onPageChange = { } + onTabClicked = { } ) } } diff --git a/core/src/main/java/org/openedx/core/ui/ComposeExtensions.kt b/core/src/main/java/org/openedx/core/ui/ComposeExtensions.kt index 1659a0417..5165619b6 100644 --- a/core/src/main/java/org/openedx/core/ui/ComposeExtensions.kt +++ b/core/src/main/java/org/openedx/core/ui/ComposeExtensions.kt @@ -9,6 +9,7 @@ import androidx.compose.foundation.clickable import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyListState +import androidx.compose.foundation.lazy.grid.LazyGridState import androidx.compose.foundation.pager.PagerState import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect @@ -77,6 +78,16 @@ fun LazyListState.shouldLoadMore(rememberedIndex: MutableState, threshold: return false } +fun LazyGridState.shouldLoadMore(rememberedIndex: MutableState, threshold: Int): Boolean { + val firstVisibleIndex = this.firstVisibleItemIndex + if (rememberedIndex.value != firstVisibleIndex) { + rememberedIndex.value = firstVisibleIndex + val lastVisibleIndex = layoutInfo.visibleItemsInfo.lastOrNull()?.index ?: 0 + return lastVisibleIndex >= layoutInfo.totalItemsCount - 1 - threshold + } + return false +} + fun Modifier.statusBarsInset(): Modifier = composed { val topInset = (LocalContext.current as? InsetHolder)?.topInset ?: 0 return@composed this diff --git a/core/src/main/java/org/openedx/core/ui/TabItem.kt b/core/src/main/java/org/openedx/core/ui/TabItem.kt index 65a88861e..d6952c010 100644 --- a/core/src/main/java/org/openedx/core/ui/TabItem.kt +++ b/core/src/main/java/org/openedx/core/ui/TabItem.kt @@ -6,5 +6,5 @@ import androidx.compose.ui.graphics.vector.ImageVector interface TabItem { @get:StringRes val labelResId: Int - val icon: ImageVector + val icon: ImageVector? } diff --git a/core/src/main/java/org/openedx/core/ui/theme/Type.kt b/core/src/main/java/org/openedx/core/ui/theme/Type.kt index 0160196f9..52d9adebb 100644 --- a/core/src/main/java/org/openedx/core/ui/theme/Type.kt +++ b/core/src/main/java/org/openedx/core/ui/theme/Type.kt @@ -17,6 +17,7 @@ data class AppTypography( val displayLarge: TextStyle, val displayMedium: TextStyle, val displaySmall: TextStyle, + val headlineBold: TextStyle, val headlineLarge: TextStyle, val headlineMedium: TextStyle, val headlineSmall: TextStyle, @@ -72,6 +73,13 @@ internal val LocalTypography = staticCompositionLocalOf { letterSpacing = 0.sp, fontFamily = fontFamily ), + headlineBold = TextStyle( + fontSize = 34.sp, + lineHeight = 24.sp, + fontWeight = FontWeight.Bold, + letterSpacing = 0.sp, + fontFamily = fontFamily + ), headlineMedium = TextStyle( fontSize = 28.sp, lineHeight = 36.sp, diff --git a/core/src/main/java/org/openedx/core/utils/FileUtil.kt b/core/src/main/java/org/openedx/core/utils/FileUtil.kt index 001d03f4f..2f5c2b2e5 100644 --- a/core/src/main/java/org/openedx/core/utils/FileUtil.kt +++ b/core/src/main/java/org/openedx/core/utils/FileUtil.kt @@ -1,11 +1,13 @@ package org.openedx.core.utils import android.content.Context +import com.google.gson.Gson +import com.google.gson.GsonBuilder import java.io.File -object FileUtil { +class FileUtil(val context: Context) { - fun getExternalAppDir(context: Context): File { + fun getExternalAppDir(): File { val dir = context.externalCacheDir.toString() + File.separator + context.getString(org.openedx.core.R.string.app_name).replace(Regex("\\s"), "_") val file = File(dir) @@ -13,7 +15,22 @@ object FileUtil { return file } + inline fun saveObjectToFile(obj: T, fileName: String = "${T::class.java.simpleName}.json") { + val gson: Gson = GsonBuilder().setPrettyPrinting().create() + val jsonString = gson.toJson(obj) + File(getExternalAppDir().path + fileName).writeText(jsonString) + } + inline fun getObjectFromFile(fileName: String = "${T::class.java.simpleName}.json"): T? { + val file = File(getExternalAppDir().path + fileName) + return if (file.exists()) { + val gson: Gson = GsonBuilder().setPrettyPrinting().create() + val jsonString = file.readText() + gson.fromJson(jsonString, T::class.java) + } else { + null + } + } } enum class Directories { 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 e23135e47..908e650a1 100644 --- a/core/src/main/java/org/openedx/core/utils/TimeUtils.kt +++ b/core/src/main/java/org/openedx/core/utils/TimeUtils.kt @@ -59,7 +59,7 @@ object TimeUtils { private fun dateToCourseDate(resourceManager: ResourceManager, date: Date?): String { return formatDate( - format = resourceManager.getString(R.string.core_date_format_MMMM_dd), date = date + format = resourceManager.getString(R.string.core_date_format_MMM_dd_yyyy), date = date ) } @@ -152,7 +152,7 @@ object TimeUtils { ) } else { resourceManager.getString( - R.string.core_label_ending, dateToCourseDate(resourceManager, end) + R.string.core_label_ends, dateToCourseDate(resourceManager, end) ) } } diff --git a/core/src/main/res/drawable/core_ic_settings.xml b/core/src/main/res/drawable/core_ic_settings.xml deleted file mode 100644 index a86316516..000000000 --- a/core/src/main/res/drawable/core_ic_settings.xml +++ /dev/null @@ -1,20 +0,0 @@ - - - - diff --git a/core/src/main/res/drawable/ic_core_chapter_icon.xml b/core/src/main/res/drawable/ic_core_chapter_icon.xml new file mode 100644 index 000000000..9ee00fed7 --- /dev/null +++ b/core/src/main/res/drawable/ic_core_chapter_icon.xml @@ -0,0 +1,30 @@ + + + + + + + + diff --git a/core/src/main/res/values-night/colors.xml b/core/src/main/res/values-night/colors.xml index 5a7d9d3bd..d6f9f1a14 100644 --- a/core/src/main/res/values-night/colors.xml +++ b/core/src/main/res/values-night/colors.xml @@ -3,4 +3,6 @@ #FF19212F #5478F9 #19212F - \ No newline at end of file + #879FF5 + #8E9BAE + diff --git a/core/src/main/res/values-uk/strings.xml b/core/src/main/res/values-uk/strings.xml index f20cd28e1..2aab8871c 100644 --- a/core/src/main/res/values-uk/strings.xml +++ b/core/src/main/res/values-uk/strings.xml @@ -13,7 +13,7 @@ Виберіть значення Починається %1$s Закінчився %1$s - Закінчується %1$s + Закінчується %1$s Термін дії курсу закінчується %1$s Термін дії курсу закінчується %1$s Термін дії курсу минув %1$s @@ -31,7 +31,7 @@ Обліковий запис користувача не активовано. Будь ласка, спочатку активуйте свій обліковий запис. Надіслати електронний лист за допомогою ... Не встановлено жодного поштового клієнта - dd MMMM + dd MMMM, yyyy dd MMM yyyy HH:mm Оновлення додатку Ми рекомендуємо вам оновитись до останньої версії. Оновіться зараз, щоб отримати останні функції та виправлення. diff --git a/core/src/main/res/values/colors.xml b/core/src/main/res/values/colors.xml index d6d7f456d..57a25d9ed 100644 --- a/core/src/main/res/values/colors.xml +++ b/core/src/main/res/values/colors.xml @@ -3,4 +3,6 @@ #FFFFFF #3C68FF #517BFE - \ No newline at end of file + #3C68FF + #97A5BB + diff --git a/core/src/main/res/values/strings.xml b/core/src/main/res/values/strings.xml index 7247f0fbe..d9f5972ac 100644 --- a/core/src/main/res/values/strings.xml +++ b/core/src/main/res/values/strings.xml @@ -18,7 +18,7 @@ Select value Starting %1$s Ended %1$s - Ending %1$s + Ends %1$s Course access expires %1$s Course access expires on %1$s Course access expired %1$s @@ -46,7 +46,7 @@ OS version: Device model: Feedback - MMMM dd + MMM dd, yyyy dd MMM yyyy hh:mm aaa App Update We recommend that you update to the latest version. Upgrade now to receive the latest features and fixes. @@ -170,7 +170,6 @@ - Home Videos Discussions diff --git a/course/src/main/java/org/openedx/course/data/storage/CourseConverter.kt b/course/src/main/java/org/openedx/course/data/storage/CourseConverter.kt index 91ac5a610..1865a3c34 100644 --- a/course/src/main/java/org/openedx/course/data/storage/CourseConverter.kt +++ b/course/src/main/java/org/openedx/course/data/storage/CourseConverter.kt @@ -4,6 +4,7 @@ import androidx.room.TypeConverter import com.google.gson.Gson import org.openedx.core.data.model.room.BlockDb import org.openedx.core.data.model.room.VideoInfoDb +import org.openedx.core.data.model.room.discovery.CourseDateBlockDb import org.openedx.core.extension.genericType class CourseConverter { @@ -57,4 +58,16 @@ class CourseConverter { return gson.toJson(map) } + @TypeConverter + fun fromListOfCourseDateBlockDb(value: List): String { + val json = Gson().toJson(value) + return json.toString() + } + + @TypeConverter + fun toListOfCourseDateBlockDb(value: String): List { + val type = genericType>() + return Gson().fromJson(value, type) + } + } diff --git a/course/src/main/java/org/openedx/course/presentation/container/CollapsingLayout.kt b/course/src/main/java/org/openedx/course/presentation/container/CollapsingLayout.kt index b5d73adaf..64ba858d8 100644 --- a/course/src/main/java/org/openedx/course/presentation/container/CollapsingLayout.kt +++ b/course/src/main/java/org/openedx/course/presentation/container/CollapsingLayout.kt @@ -64,7 +64,6 @@ import androidx.compose.ui.unit.dp import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.delay import kotlinx.coroutines.launch -import org.openedx.core.presentation.course.CourseContainerTab import org.openedx.core.ui.RoundTabsBar import org.openedx.core.ui.displayCutoutForLandscape import org.openedx.core.ui.rememberWindowSize @@ -748,8 +747,7 @@ private fun CollapsingLayoutPreview() { RoundTabsBar( items = CourseContainerTab.entries, rowState = rememberLazyListState(), - pagerState = rememberPagerState(pageCount = { 5 }), - onPageChange = { } + pagerState = rememberPagerState(pageCount = { CourseContainerTab.entries.size }) ) }, onBackClick = {}, 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 3f13c506c..4df1fcf64 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 @@ -5,6 +5,7 @@ import android.view.View import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth @@ -53,7 +54,6 @@ import org.koin.androidx.compose.koinViewModel import org.koin.androidx.viewmodel.ext.android.viewModel import org.koin.core.parameter.parametersOf import org.openedx.core.extension.takeIfNotEmpty -import org.openedx.core.presentation.course.CourseContainerTab import org.openedx.core.presentation.global.viewBinding import org.openedx.core.presentation.settings.calendarsync.CalendarSyncDialog import org.openedx.core.presentation.settings.calendarsync.CalendarSyncDialogType @@ -83,7 +83,8 @@ class CourseContainerFragment : Fragment(R.layout.fragment_course_container) { parametersOf( requireArguments().getString(ARG_COURSE_ID, ""), requireArguments().getString(ARG_TITLE, ""), - requireArguments().getString(ARG_ENROLLMENT_MODE, "") + requireArguments().getString(ARG_ENROLLMENT_MODE, ""), + requireArguments().getString(ARG_RESUME_BLOCK, "") ) } @@ -260,16 +261,22 @@ class CourseContainerFragment : Fragment(R.layout.fragment_course_container) { const val ARG_COURSE_ID = "courseId" const val ARG_TITLE = "title" const val ARG_ENROLLMENT_MODE = "enrollmentMode" + const val ARG_OPEN_TAB = "open_tab" + const val ARG_RESUME_BLOCK = "resume_block" fun newInstance( courseId: String, courseTitle: String, enrollmentMode: String, + openTab: String = CourseContainerTab.HOME.name, + resumeBlockId: String = "" ): CourseContainerFragment { val fragment = CourseContainerFragment() fragment.arguments = bundleOf( ARG_COURSE_ID to courseId, ARG_TITLE to courseTitle, - ARG_ENROLLMENT_MODE to enrollmentMode + ARG_ENROLLMENT_MODE to enrollmentMode, + ARG_OPEN_TAB to openTab, + ARG_RESUME_BLOCK to resumeBlockId ) return fragment } @@ -300,9 +307,21 @@ fun CourseDashboard( val refreshing by viewModel.refreshing.collectAsState(true) val courseImage by viewModel.courseImage.collectAsState() val uiMessage by viewModel.uiMessage.collectAsState(null) - val dataReady = viewModel.dataReady.observeAsState() + val openTab = bundle.getString(CourseContainerFragment.ARG_OPEN_TAB, CourseContainerTab.HOME.name) + val requiredTab = when (openTab.uppercase()) { + CourseContainerTab.HOME.name -> CourseContainerTab.HOME + CourseContainerTab.VIDEOS.name -> CourseContainerTab.VIDEOS + CourseContainerTab.DATES.name -> CourseContainerTab.DATES + CourseContainerTab.DISCUSSIONS.name -> CourseContainerTab.DISCUSSIONS + CourseContainerTab.MORE.name -> CourseContainerTab.MORE + else -> CourseContainerTab.HOME + } - val pagerState = rememberPagerState(pageCount = { CourseContainerTab.entries.size }) + val pagerState = rememberPagerState( + initialPage = CourseContainerTab.entries.indexOf(requiredTab), + pageCount = { CourseContainerTab.entries.size } + ) + val dataReady = viewModel.dataReady.observeAsState() val tabState = rememberLazyListState() val snackState = remember { SnackbarHostState() } val pullRefreshState = rememberPullRefreshState( @@ -347,9 +366,11 @@ fun CourseDashboard( if (isNavigationEnabled) { RoundTabsBar( items = CourseContainerTab.entries, + contentPadding = PaddingValues(horizontal = 12.dp, vertical = 16.dp), rowState = tabState, pagerState = pagerState, - onPageChange = viewModel::courseContainerTabClickedEvent + withPager = true, + onTabClicked = viewModel::courseContainerTabClickedEvent ) } else { Spacer(modifier = Modifier.height(52.dp)) @@ -435,7 +456,7 @@ fun DashboardPager( CourseContainerTab.HOME -> { CourseOutlineScreen( windowSize = windowSize, - courseOutlineViewModel = koinViewModel( + viewModel = koinViewModel( parameters = { parametersOf( bundle.getString(CourseContainerFragment.ARG_COURSE_ID, ""), @@ -443,7 +464,6 @@ fun DashboardPager( ) } ), - courseRouter = viewModel.courseRouter, fragmentManager = fragmentManager, onResetDatesClick = { viewModel.onRefresh(CourseContainerTab.DATES) @@ -454,7 +474,7 @@ fun DashboardPager( CourseContainerTab.VIDEOS -> { CourseVideosScreen( windowSize = windowSize, - courseVideoViewModel = koinViewModel( + viewModel = koinViewModel( parameters = { parametersOf( bundle.getString(CourseContainerFragment.ARG_COURSE_ID, ""), @@ -462,14 +482,13 @@ fun DashboardPager( ) } ), - fragmentManager = fragmentManager, - courseRouter = viewModel.courseRouter, + fragmentManager = fragmentManager ) } CourseContainerTab.DATES -> { CourseDatesScreen( - courseDatesViewModel = koinViewModel( + viewModel = koinViewModel( parameters = { parametersOf( bundle.getString(CourseContainerFragment.ARG_COURSE_ID, ""), @@ -479,7 +498,6 @@ fun DashboardPager( } ), windowSize = windowSize, - courseRouter = viewModel.courseRouter, fragmentManager = fragmentManager, isFragmentResumed = isResumed, updateCourseStructure = { diff --git a/core/src/main/java/org/openedx/core/presentation/course/CourseContainerTab.kt b/course/src/main/java/org/openedx/course/presentation/container/CourseContainerTab.kt similarity index 52% rename from core/src/main/java/org/openedx/core/presentation/course/CourseContainerTab.kt rename to course/src/main/java/org/openedx/course/presentation/container/CourseContainerTab.kt index 51d235c36..fbdbb60fc 100644 --- a/core/src/main/java/org/openedx/core/presentation/course/CourseContainerTab.kt +++ b/course/src/main/java/org/openedx/course/presentation/container/CourseContainerTab.kt @@ -1,4 +1,4 @@ -package org.openedx.core.presentation.course +package org.openedx.course.presentation.container import androidx.annotation.StringRes import androidx.compose.material.icons.Icons @@ -8,17 +8,17 @@ import androidx.compose.material.icons.filled.Home import androidx.compose.material.icons.outlined.CalendarMonth import androidx.compose.material.icons.rounded.PlayCircleFilled import androidx.compose.ui.graphics.vector.ImageVector -import org.openedx.core.R import org.openedx.core.ui.TabItem +import org.openedx.course.R enum class CourseContainerTab( @StringRes override val labelResId: Int, - override val icon: ImageVector + override val icon: ImageVector, ) : TabItem { - HOME(R.string.core_course_container_nav_home, Icons.Default.Home), - VIDEOS(R.string.core_course_container_nav_videos, Icons.Rounded.PlayCircleFilled), - DATES(R.string.core_course_container_nav_dates, Icons.Outlined.CalendarMonth), - DISCUSSIONS(R.string.core_course_container_nav_discussions, Icons.AutoMirrored.Filled.Chat), - MORE(R.string.core_course_container_nav_more, Icons.AutoMirrored.Filled.TextSnippet) + HOME(R.string.course_container_nav_home, Icons.Default.Home), + VIDEOS(R.string.course_container_nav_videos, Icons.Rounded.PlayCircleFilled), + DATES(R.string.course_container_nav_dates, Icons.Outlined.CalendarMonth), + DISCUSSIONS(R.string.course_container_nav_discussions, Icons.AutoMirrored.Filled.Chat), + MORE(R.string.course_container_nav_more, Icons.AutoMirrored.Filled.TextSnippet) } diff --git a/course/src/main/java/org/openedx/course/presentation/container/CourseContainerViewModel.kt b/course/src/main/java/org/openedx/course/presentation/container/CourseContainerViewModel.kt index 86d20bc3c..4e233e3d7 100644 --- a/course/src/main/java/org/openedx/course/presentation/container/CourseContainerViewModel.kt +++ b/course/src/main/java/org/openedx/course/presentation/container/CourseContainerViewModel.kt @@ -25,7 +25,6 @@ import org.openedx.core.config.Config import org.openedx.core.data.storage.CorePreferences import org.openedx.core.exception.NoCachedDataException import org.openedx.core.extension.isInternetError -import org.openedx.core.presentation.course.CourseContainerTab import org.openedx.core.presentation.settings.calendarsync.CalendarSyncDialogType import org.openedx.core.presentation.settings.calendarsync.CalendarSyncUIState import org.openedx.core.system.CalendarManager @@ -37,8 +36,10 @@ import org.openedx.core.system.notifier.CourseCompletionSet import org.openedx.core.system.notifier.CourseDatesShifted import org.openedx.core.system.notifier.CourseLoading import org.openedx.core.system.notifier.CourseNotifier -import org.openedx.core.system.notifier.CourseRefresh +import org.openedx.core.system.notifier.CourseOpenBlock import org.openedx.core.system.notifier.CourseStructureUpdated +import org.openedx.core.system.notifier.RefreshDates +import org.openedx.core.system.notifier.RefreshDiscussions import org.openedx.core.utils.TimeUtils import org.openedx.course.DatesShiftedSnackBar import org.openedx.course.data.storage.CoursePreferences @@ -56,6 +57,7 @@ import org.openedx.core.R as CoreR class CourseContainerViewModel( val courseId: String, var courseName: String, + private var resumeBlockId: String, private val enrollmentMode: String, private val config: Config, private val interactor: CourseInteractor, @@ -67,7 +69,7 @@ class CourseContainerViewModel( private val coursePreferences: CoursePreferences, private val courseAnalytics: CourseAnalytics, private val imageProcessor: ImageProcessor, - val courseRouter: CourseRouter + val courseRouter: CourseRouter, ) : BaseViewModel() { private val _dataReady = MutableLiveData() @@ -179,6 +181,10 @@ class CourseContainerViewModel( } isReady } + if (_dataReady.value == true && resumeBlockId.isNotEmpty()) { + delay(500L) + courseNotifier.send(CourseOpenBlock(resumeBlockId)) + } } catch (e: Exception) { if (e.isInternetError() || e is NoCachedDataException) { _errorMessage.value = @@ -221,13 +227,13 @@ class CourseContainerViewModel( CourseContainerTab.DATES -> { viewModelScope.launch { - courseNotifier.send(CourseRefresh(courseContainerTab)) + courseNotifier.send(RefreshDates) } } CourseContainerTab.DISCUSSIONS -> { viewModelScope.launch { - courseNotifier.send(CourseRefresh(courseContainerTab)) + courseNotifier.send(RefreshDiscussions) } } @@ -265,7 +271,6 @@ class CourseContainerViewModel( } } - fun setCalendarSyncDialogType(dialogType: CalendarSyncDialogType) { val currentState = _calendarSyncUIState.value if (currentState.dialogType != dialogType) { 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 6e875d263..7381402b2 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 @@ -86,7 +86,6 @@ import org.openedx.core.ui.windowSizeValue import org.openedx.core.utils.TimeUtils import org.openedx.core.utils.clearTime import org.openedx.course.R -import org.openedx.course.presentation.CourseRouter import org.openedx.course.presentation.ui.CourseDatesBanner import org.openedx.course.presentation.ui.CourseDatesBannerTablet import java.util.concurrent.atomic.AtomicReference @@ -95,50 +94,49 @@ import org.openedx.core.R as CoreR @Composable fun CourseDatesScreen( windowSize: WindowSize, - courseDatesViewModel: CourseDatesViewModel, - courseRouter: CourseRouter, + viewModel: CourseDatesViewModel, fragmentManager: FragmentManager, isFragmentResumed: Boolean, updateCourseStructure: () -> Unit ) { - val uiState by courseDatesViewModel.uiState.observeAsState(DatesUIState.Loading) - val uiMessage by courseDatesViewModel.uiMessage.collectAsState(null) - val calendarSyncUIState by courseDatesViewModel.calendarSyncUIState.collectAsState() + val uiState by viewModel.uiState.observeAsState(DatesUIState.Loading) + val uiMessage by viewModel.uiMessage.collectAsState(null) + val calendarSyncUIState by viewModel.calendarSyncUIState.collectAsState() val context = LocalContext.current CourseDatesUI( windowSize = windowSize, uiState = uiState, uiMessage = uiMessage, - isSelfPaced = courseDatesViewModel.isSelfPaced, + isSelfPaced = viewModel.isSelfPaced, calendarSyncUIState = calendarSyncUIState, onItemClick = { block -> if (block.blockId.isNotEmpty()) { - courseDatesViewModel.getVerticalBlock(block.blockId) + viewModel.getVerticalBlock(block.blockId) ?.let { verticalBlock -> - courseDatesViewModel.logCourseComponentTapped(true, block) - if (courseDatesViewModel.isCourseExpandableSectionsEnabled) { - courseRouter.navigateToCourseContainer( + viewModel.logCourseComponentTapped(true, block) + if (viewModel.isCourseExpandableSectionsEnabled) { + viewModel.courseRouter.navigateToCourseContainer( fm = fragmentManager, - courseId = courseDatesViewModel.courseId, + courseId = viewModel.courseId, unitId = verticalBlock.id, componentId = "", mode = CourseViewMode.FULL ) } else { - courseDatesViewModel.getSequentialBlock(verticalBlock.id) + viewModel.getSequentialBlock(verticalBlock.id) ?.let { sequentialBlock -> - courseRouter.navigateToCourseSubsections( + viewModel.courseRouter.navigateToCourseSubsections( fm = fragmentManager, subSectionId = sequentialBlock.id, - courseId = courseDatesViewModel.courseId, + courseId = viewModel.courseId, unitId = verticalBlock.id, mode = CourseViewMode.FULL ) } } } ?: { - courseDatesViewModel.logCourseComponentTapped(false, block) + viewModel.logCourseComponentTapped(false, block) ActionDialogFragment.newInstance( title = context.getString(CoreR.string.core_leaving_the_app), message = context.getString( @@ -157,20 +155,20 @@ fun CourseDatesScreen( }, onPLSBannerViewed = { if (isFragmentResumed) { - courseDatesViewModel.logPlsBannerViewed() + viewModel.logPlsBannerViewed() } }, onSyncDates = { - courseDatesViewModel.logPlsShiftButtonClicked() - courseDatesViewModel.resetCourseDatesBanner { - courseDatesViewModel.logPlsShiftDates(it) + viewModel.logPlsShiftButtonClicked() + viewModel.resetCourseDatesBanner { + viewModel.logPlsShiftDates(it) if (it) { updateCourseStructure() } } }, onCalendarSyncSwitch = { isChecked -> - courseDatesViewModel.handleCalendarSyncState(isChecked) + viewModel.handleCalendarSyncState(isChecked) }, ) } diff --git a/course/src/main/java/org/openedx/course/presentation/dates/CourseDatesViewModel.kt b/course/src/main/java/org/openedx/course/presentation/dates/CourseDatesViewModel.kt index c72728fea..247108ff9 100644 --- a/course/src/main/java/org/openedx/course/presentation/dates/CourseDatesViewModel.kt +++ b/course/src/main/java/org/openedx/course/presentation/dates/CourseDatesViewModel.kt @@ -26,18 +26,18 @@ import org.openedx.core.extension.isInternetError import org.openedx.core.presentation.settings.calendarsync.CalendarSyncDialogType import org.openedx.core.presentation.settings.calendarsync.CalendarSyncUIState import org.openedx.core.system.CalendarManager -import org.openedx.core.presentation.course.CourseContainerTab import org.openedx.core.system.ResourceManager import org.openedx.core.system.notifier.CalendarSyncEvent.CheckCalendarSyncEvent import org.openedx.core.system.notifier.CalendarSyncEvent.CreateCalendarSyncEvent import org.openedx.core.system.notifier.CourseDatesShifted import org.openedx.core.system.notifier.CourseLoading import org.openedx.core.system.notifier.CourseNotifier -import org.openedx.core.system.notifier.CourseRefresh +import org.openedx.core.system.notifier.RefreshDates 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.core.R as CoreR class CourseDatesViewModel( @@ -51,6 +51,7 @@ class CourseDatesViewModel( private val corePreferences: CorePreferences, private val courseAnalytics: CourseAnalytics, private val config: Config, + val courseRouter: CourseRouter ) : BaseViewModel() { var isSelfPaced = true @@ -86,10 +87,8 @@ class CourseDatesViewModel( _calendarSyncUIState.update { it.copy(isSynced = event.isSynced) } } - is CourseRefresh -> { - if (event.courseContainerTab == CourseContainerTab.DATES) { - loadingCourseDatesInternal() - } + is RefreshDates -> { + loadingCourseDatesInternal() } } } diff --git a/course/src/main/java/org/openedx/course/presentation/outline/CourseOutlineScreen.kt b/course/src/main/java/org/openedx/course/presentation/outline/CourseOutlineScreen.kt index c050ff447..464fa163b 100644 --- a/course/src/main/java/org/openedx/course/presentation/outline/CourseOutlineScreen.kt +++ b/course/src/main/java/org/openedx/course/presentation/outline/CourseOutlineScreen.kt @@ -25,6 +25,7 @@ import androidx.compose.material.Surface import androidx.compose.material.Text 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 @@ -63,8 +64,8 @@ import org.openedx.core.ui.theme.OpenEdXTheme import org.openedx.core.ui.theme.appColors import org.openedx.core.ui.theme.appTypography import org.openedx.core.ui.windowSizeValue +import org.openedx.core.utils.FileUtil import org.openedx.course.R -import org.openedx.course.presentation.CourseRouter import org.openedx.course.presentation.ui.CourseDatesBanner import org.openedx.course.presentation.ui.CourseDatesBannerTablet import org.openedx.course.presentation.ui.CourseSection @@ -72,105 +73,98 @@ import org.openedx.course.presentation.ui.CourseExpandableChapterCard import org.openedx.course.presentation.ui.CourseMessage import org.openedx.course.presentation.ui.CourseSectionCard import org.openedx.course.presentation.ui.CourseSubSectionItem -import java.io.File import java.util.Date import org.openedx.core.R as CoreR @Composable fun CourseOutlineScreen( windowSize: WindowSize, - courseOutlineViewModel: CourseOutlineViewModel, - courseRouter: CourseRouter, + viewModel: CourseOutlineViewModel, fragmentManager: FragmentManager, onResetDatesClick: () -> Unit ) { - val uiState by courseOutlineViewModel.uiState.collectAsState() - val uiMessage by courseOutlineViewModel.uiMessage.collectAsState(null) + 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) + } + } + CourseOutlineUI( windowSize = windowSize, uiState = uiState, uiMessage = uiMessage, onExpandClick = { block -> - if (courseOutlineViewModel.switchCourseSections(block.id)) { - courseOutlineViewModel.sequentialClickedEvent( + if (viewModel.switchCourseSections(block.id)) { + viewModel.sequentialClickedEvent( block.blockId, block.displayName ) } }, onSubSectionClick = { subSectionBlock -> - if (courseOutlineViewModel.isCourseNestedListEnabled) { - courseOutlineViewModel.courseSubSectionUnit[subSectionBlock.id]?.let { unit -> - courseOutlineViewModel.logUnitDetailViewedEvent( + if (viewModel.isCourseNestedListEnabled) { + viewModel.courseSubSectionUnit[subSectionBlock.id]?.let { unit -> + viewModel.logUnitDetailViewedEvent( unit.blockId, unit.displayName ) - courseRouter.navigateToCourseContainer( + viewModel.courseRouter.navigateToCourseContainer( fragmentManager, - courseId = courseOutlineViewModel.courseId, + courseId = viewModel.courseId, unitId = unit.id, mode = CourseViewMode.FULL ) } } else { - courseOutlineViewModel.sequentialClickedEvent( + viewModel.sequentialClickedEvent( subSectionBlock.blockId, subSectionBlock.displayName ) - courseRouter.navigateToCourseSubsections( + viewModel.courseRouter.navigateToCourseSubsections( fm = fragmentManager, - courseId = courseOutlineViewModel.courseId, + courseId = viewModel.courseId, subSectionId = subSectionBlock.id, mode = CourseViewMode.FULL ) } }, onResumeClick = { componentId -> - courseOutlineViewModel.resumeSectionBlock?.let { subSection -> - courseOutlineViewModel.resumeCourseTappedEvent(subSection.id) - courseOutlineViewModel.resumeVerticalBlock?.let { unit -> - courseRouter.navigateToCourseContainer( - fm = fragmentManager, - courseId = courseOutlineViewModel.courseId, - unitId = unit.id, - componentId = componentId, - mode = CourseViewMode.FULL - ) - } - } + viewModel.openBlock( + fragmentManager, + componentId + ) }, onDownloadClick = { blocksIds -> blocksIds.forEach { blockId -> - if (courseOutlineViewModel.isBlockDownloading(blockId)) { - courseRouter.navigateToDownloadQueue( + if (viewModel.isBlockDownloading(blockId)) { + viewModel.courseRouter.navigateToDownloadQueue( fm = fragmentManager, - courseOutlineViewModel.getDownloadableChildren(blockId) + viewModel.getDownloadableChildren(blockId) ?: arrayListOf() ) - } else if (courseOutlineViewModel.isBlockDownloaded(blockId)) { - courseOutlineViewModel.removeDownloadModels(blockId) + } else if (viewModel.isBlockDownloaded(blockId)) { + viewModel.removeDownloadModels(blockId) } else { - courseOutlineViewModel.saveDownloadModels( - context.externalCacheDir.toString() + - File.separator + - context - .getString(CoreR.string.app_name) - .replace(Regex("\\s"), "_"), blockId + viewModel.saveDownloadModels( + FileUtil.getExternalAppDir(context).path, blockId ) } } }, onResetDatesClick = { - courseOutlineViewModel.resetCourseDatesBanner( + viewModel.resetCourseDatesBanner( onResetDates = { onResetDatesClick() } ) }, onCertificateClick = { - courseOutlineViewModel.viewCertificateTappedEvent() + viewModel.viewCertificateTappedEvent() it.takeIfNotEmpty() ?.let { url -> AndroidUriHandler(context).openUri(url) } } diff --git a/course/src/main/java/org/openedx/course/presentation/outline/CourseOutlineViewModel.kt b/course/src/main/java/org/openedx/course/presentation/outline/CourseOutlineViewModel.kt index 39bae146f..25436c3a6 100644 --- a/course/src/main/java/org/openedx/course/presentation/outline/CourseOutlineViewModel.kt +++ b/course/src/main/java/org/openedx/course/presentation/outline/CourseOutlineViewModel.kt @@ -1,5 +1,6 @@ package org.openedx.course.presentation.outline +import androidx.fragment.app.FragmentManager import androidx.lifecycle.viewModelScope import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow @@ -25,6 +26,7 @@ import org.openedx.core.module.DownloadWorkerController import org.openedx.core.module.db.DownloadDao import org.openedx.core.module.download.BaseDownloadViewModel import org.openedx.core.presentation.CoreAnalytics +import org.openedx.core.presentation.course.CourseViewMode import org.openedx.core.presentation.settings.calendarsync.CalendarSyncDialogType import org.openedx.core.system.ResourceManager import org.openedx.core.system.connection.NetworkConnection @@ -32,11 +34,13 @@ import org.openedx.core.system.notifier.CalendarSyncEvent.CreateCalendarSyncEven import org.openedx.core.system.notifier.CourseDatesShifted import org.openedx.core.system.notifier.CourseLoading import org.openedx.core.system.notifier.CourseNotifier +import org.openedx.core.system.notifier.CourseOpenBlock import org.openedx.core.system.notifier.CourseStructureUpdated import org.openedx.course.domain.interactor.CourseInteractor import org.openedx.course.presentation.CourseAnalytics import org.openedx.course.presentation.CourseAnalyticsEvent import org.openedx.course.presentation.CourseAnalyticsKey +import org.openedx.course.presentation.CourseRouter import org.openedx.course.R as courseR class CourseOutlineViewModel( @@ -49,6 +53,7 @@ class CourseOutlineViewModel( private val networkConnection: NetworkConnection, private val preferencesManager: CorePreferences, private val analytics: CourseAnalytics, + val courseRouter: CourseRouter, coreAnalytics: CoreAnalytics, downloadDao: DownloadDao, workerController: DownloadWorkerController, @@ -69,10 +74,14 @@ class CourseOutlineViewModel( val uiMessage: SharedFlow get() = _uiMessage.asSharedFlow() - var resumeSectionBlock: Block? = null - private set - var resumeVerticalBlock: Block? = null - private set + 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.isCourseDropdownNavigationEnabled() private val courseSubSections = mutableMapOf>() private val subSectionsDownloadsCount = mutableMapOf() @@ -87,6 +96,10 @@ class CourseOutlineViewModel( updateCourseData() } } + + is CourseOpenBlock -> { + _resumeBlockId.emit(event.blockId) + } } } } @@ -270,6 +283,41 @@ class CourseOutlineViewModel( } } + fun openBlock(fragmentManager: FragmentManager, blockId: String) { + viewModelScope.launch { + val courseStructure = interactor.getCourseStructure(courseId, false) + val blocks = courseStructure.blockData + getResumeBlock(blocks, blockId) + resumeBlock(fragmentManager, blockId) + } + } + + private fun resumeBlock(fragmentManager: FragmentManager, blockId: String) { + resumeSectionBlock?.let { subSection -> + resumeCourseTappedEvent(subSection.id) + resumeVerticalBlock?.let { unit -> + if (isCourseExpandableSectionsEnabled) { + courseRouter.navigateToCourseContainer( + fm = fragmentManager, + courseId = courseId, + unitId = unit.id, + componentId = blockId, + mode = CourseViewMode.FULL + ) + } else { + courseRouter.navigateToCourseSubsections( + fragmentManager, + courseId = courseId, + subSectionId = subSection.id, + mode = CourseViewMode.FULL, + unitId = unit.id, + componentId = blockId + ) + } + } + } + } + fun viewCertificateTappedEvent() { analytics.logEvent( CourseAnalyticsEvent.VIEW_CERTIFICATE.eventName, @@ -280,7 +328,7 @@ class CourseOutlineViewModel( ) } - fun resumeCourseTappedEvent(blockId: String) { + private fun resumeCourseTappedEvent(blockId: String) { val currentState = uiState.value if (currentState is CourseOutlineUIState.CourseData) { analytics.logEvent( 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 2524832bd..0c83b264b 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 @@ -79,11 +79,14 @@ import org.openedx.core.ui.theme.appColors import org.openedx.core.ui.theme.appShapes import org.openedx.core.ui.theme.appTypography import org.openedx.core.ui.windowSizeValue +import org.openedx.core.ui.windowSizeValue +import org.openedx.core.utils.FileUtil import org.openedx.course.R import org.openedx.course.presentation.CourseRouter import org.openedx.course.presentation.ui.CardArrow import java.io.File import java.util.Date +import org.openedx.core.R as CoreR class CourseSectionFragment : Fragment() { @@ -135,11 +138,7 @@ class CourseSectionFragment : Fragment() { viewModel.removeDownloadModels(it.id) } else { viewModel.saveDownloadModels( - requireContext().externalCacheDir.toString() + - File.separator + - requireContext() - .getString(org.openedx.core.R.string.app_name) - .replace(Regex("\\s"), "_"), it.id + FileUtil(context).getExternalAppDir().path, it.id ) } } @@ -311,7 +310,7 @@ private fun CourseSubsectionItem( ) { val completedIconPainter = if (block.isCompleted()) painterResource(R.drawable.course_ic_task_alt) else painterResource( - R.drawable.ic_course_chapter_icon + CoreR.drawable.ic_core_chapter_icon ) val completedIconColor = if (block.isCompleted()) MaterialTheme.appColors.primary else MaterialTheme.appColors.onSurface 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 aae470213..fc3be04d4 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 @@ -130,7 +130,7 @@ fun CourseSectionCard( ) { val completedIconPainter = if (block.isCompleted()) painterResource(R.drawable.course_ic_task_alt) else painterResource( - R.drawable.ic_course_chapter_icon + coreR.drawable.ic_core_chapter_icon ) val completedIconColor = if (block.isCompleted()) MaterialTheme.appColors.primary else MaterialTheme.appColors.onSurface diff --git a/course/src/main/java/org/openedx/course/presentation/ui/CourseVideosUI.kt b/course/src/main/java/org/openedx/course/presentation/ui/CourseVideosUI.kt index 4acbfe070..12a11cd14 100644 --- a/course/src/main/java/org/openedx/course/presentation/ui/CourseVideosUI.kt +++ b/course/src/main/java/org/openedx/course/presentation/ui/CourseVideosUI.kt @@ -76,51 +76,49 @@ import org.openedx.core.ui.theme.OpenEdXTheme import org.openedx.core.ui.theme.appColors import org.openedx.core.ui.theme.appTypography import org.openedx.core.ui.windowSizeValue +import org.openedx.core.utils.FileUtil import org.openedx.course.R -import org.openedx.course.presentation.CourseRouter import org.openedx.course.presentation.videos.CourseVideoViewModel import org.openedx.course.presentation.videos.CourseVideosUIState -import java.io.File import java.util.Date @Composable fun CourseVideosScreen( windowSize: WindowSize, - courseVideoViewModel: CourseVideoViewModel, - fragmentManager: FragmentManager, - courseRouter: CourseRouter + viewModel: CourseVideoViewModel, + fragmentManager: FragmentManager ) { - val uiState by courseVideoViewModel.uiState.collectAsState(CourseVideosUIState.Loading) - val uiMessage by courseVideoViewModel.uiMessage.collectAsState(null) - val videoSettings by courseVideoViewModel.videoSettings.collectAsState() + val uiState by viewModel.uiState.collectAsState(CourseVideosUIState.Loading) + val uiMessage by viewModel.uiMessage.collectAsState(null) + val videoSettings by viewModel.videoSettings.collectAsState() val context = LocalContext.current CourseVideosUI( windowSize = windowSize, uiState = uiState, uiMessage = uiMessage, - courseTitle = courseVideoViewModel.courseTitle, + courseTitle = viewModel.courseTitle, videoSettings = videoSettings, onItemClick = { block -> - courseRouter.navigateToCourseSubsections( + viewModel.courseRouter.navigateToCourseSubsections( fm = fragmentManager, - courseId = courseVideoViewModel.courseId, + courseId = viewModel.courseId, subSectionId = block.id, mode = CourseViewMode.VIDEOS ) }, onExpandClick = { block -> - courseVideoViewModel.switchCourseSections(block.id) + viewModel.switchCourseSections(block.id) }, onSubSectionClick = { subSectionBlock -> - courseVideoViewModel.courseSubSectionUnit[subSectionBlock.id]?.let { unit -> - courseVideoViewModel.sequentialClickedEvent( + viewModel.courseSubSectionUnit[subSectionBlock.id]?.let { unit -> + viewModel.sequentialClickedEvent( unit.blockId, unit.displayName ) - courseRouter.navigateToCourseContainer( + viewModel.courseRouter.navigateToCourseContainer( fm = fragmentManager, - courseId = courseVideoViewModel.courseId, + courseId = viewModel.courseId, unitId = unit.id, mode = CourseViewMode.VIDEOS ) @@ -138,39 +136,31 @@ fun CourseVideosScreen( courseVideoViewModel.removeDownloadModels(blockId) } else { courseVideoViewModel.saveDownloadModels( - context.externalCacheDir.toString() + - File.separator + - context - .getString(org.openedx.core.R.string.app_name) - .replace(Regex("\\s"), "_"), blockId + FileUtil(context).getExternalAppDir().path, blockId ) } } }, onDownloadAllClick = { isAllBlocksDownloadedOrDownloading -> - courseVideoViewModel.logBulkDownloadToggleEvent(!isAllBlocksDownloadedOrDownloading) + viewModel.logBulkDownloadToggleEvent(!isAllBlocksDownloadedOrDownloading) if (isAllBlocksDownloadedOrDownloading) { - courseVideoViewModel.removeAllDownloadModels() + viewModel.removeAllDownloadModels() } else { - courseVideoViewModel.saveAllDownloadModels( - context.externalCacheDir.toString() + - File.separator + - context - .getString(org.openedx.core.R.string.app_name) - .replace(Regex("\\s"), "_") + viewModel.saveAllDownloadModels( + FileUtil(context).getExternalAppDir().path ) } }, onDownloadQueueClick = { - if (courseVideoViewModel.hasDownloadModelsInQueue()) { - courseRouter.navigateToDownloadQueue(fm = fragmentManager) + if (viewModel.hasDownloadModelsInQueue()) { + viewModel.courseRouter.navigateToDownloadQueue(fm = fragmentManager) } }, onVideoDownloadQualityClick = { - if (courseVideoViewModel.hasDownloadModelsInQueue()) { - courseVideoViewModel.onChangingVideoQualityWhileDownloading() + if (viewModel.hasDownloadModelsInQueue()) { + viewModel.onChangingVideoQualityWhileDownloading() } else { - courseRouter.navigateToVideoQuality( + viewModel.courseRouter.navigateToVideoQuality( fragmentManager, VideoQualityType.Download ) 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 19940fb2c..067e20fea 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 @@ -28,6 +28,7 @@ import org.openedx.core.system.notifier.VideoQualityChanged import org.openedx.course.R import org.openedx.course.domain.interactor.CourseInteractor import org.openedx.course.presentation.CourseAnalytics +import org.openedx.course.presentation.CourseRouter class CourseVideoViewModel( val courseId: String, @@ -40,6 +41,7 @@ class CourseVideoViewModel( private val courseNotifier: CourseNotifier, private val videoNotifier: VideoNotifier, private val analytics: CourseAnalytics, + val courseRouter: CourseRouter, coreAnalytics: CoreAnalytics, downloadDao: DownloadDao, workerController: DownloadWorkerController diff --git a/course/src/main/res/drawable/ic_course_chapter_icon.xml b/course/src/main/res/drawable/ic_course_chapter_icon.xml deleted file mode 100644 index eaf899ce2..000000000 --- a/course/src/main/res/drawable/ic_course_chapter_icon.xml +++ /dev/null @@ -1,31 +0,0 @@ - - - - - - - - diff --git a/course/src/main/res/values/strings.xml b/course/src/main/res/values/strings.xml index 337a63038..adbd7bbb6 100644 --- a/course/src/main/res/values/strings.xml +++ b/course/src/main/res/values/strings.xml @@ -41,6 +41,12 @@ Course dates are not currently available. + Home + Videos + Discussions + More + Dates + Video player Remove course section diff --git a/course/src/test/java/org/openedx/course/presentation/container/CourseContainerViewModelTest.kt b/course/src/test/java/org/openedx/course/presentation/container/CourseContainerViewModelTest.kt index b32457e4b..938d850d2 100644 --- a/course/src/test/java/org/openedx/course/presentation/container/CourseContainerViewModelTest.kt +++ b/course/src/test/java/org/openedx/course/presentation/container/CourseContainerViewModelTest.kt @@ -156,6 +156,7 @@ class CourseContainerViewModelTest { "", "", "", + "", config, interactor, calendarManager, @@ -189,6 +190,7 @@ class CourseContainerViewModelTest { "", "", "", + "", config, interactor, calendarManager, @@ -222,6 +224,7 @@ class CourseContainerViewModelTest { "", "", "", + "", config, interactor, calendarManager, @@ -254,6 +257,7 @@ class CourseContainerViewModelTest { "", "", "", + "", config, interactor, calendarManager, @@ -289,6 +293,7 @@ class CourseContainerViewModelTest { "", "", "", + "", config, interactor, calendarManager, @@ -319,6 +324,7 @@ class CourseContainerViewModelTest { "", "", "", + "", config, interactor, calendarManager, @@ -349,6 +355,7 @@ class CourseContainerViewModelTest { "", "", "", + "", config, interactor, calendarManager, diff --git a/course/src/test/java/org/openedx/course/presentation/dates/CourseDatesViewModelTest.kt b/course/src/test/java/org/openedx/course/presentation/dates/CourseDatesViewModelTest.kt index ac46234d4..11ffb4932 100644 --- a/course/src/test/java/org/openedx/course/presentation/dates/CourseDatesViewModelTest.kt +++ b/course/src/test/java/org/openedx/course/presentation/dates/CourseDatesViewModelTest.kt @@ -44,6 +44,7 @@ import org.openedx.core.system.notifier.CourseLoading 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.CourseRouter import java.net.UnknownHostException import java.util.Date @@ -61,6 +62,7 @@ class CourseDatesViewModelTest { private val corePreferences = mockk() private val analytics = mockk() private val config = mockk() + private val courseRouter = mockk() private val openEdx = "OpenEdx" private val calendarTitle = "OpenEdx - Abc" @@ -171,7 +173,8 @@ class CourseDatesViewModelTest { resourceManager, corePreferences, analytics, - config + config, + courseRouter ) coEvery { interactor.getCourseDates(any()) } throws UnknownHostException() val message = async { @@ -199,7 +202,8 @@ class CourseDatesViewModelTest { resourceManager, corePreferences, analytics, - config + config, + courseRouter ) coEvery { interactor.getCourseDates(any()) } throws Exception() val message = async { @@ -227,7 +231,8 @@ class CourseDatesViewModelTest { resourceManager, corePreferences, analytics, - config + config, + courseRouter ) coEvery { interactor.getCourseDates(any()) } returns mockedCourseDatesResult val message = async { @@ -255,7 +260,8 @@ class CourseDatesViewModelTest { resourceManager, corePreferences, analytics, - config + config, + courseRouter ) coEvery { interactor.getCourseDates(any()) } returns CourseDatesResult( datesSection = linkedMapOf(), diff --git a/course/src/test/java/org/openedx/course/presentation/outline/CourseOutlineViewModelTest.kt b/course/src/test/java/org/openedx/course/presentation/outline/CourseOutlineViewModelTest.kt index d99138499..d3c26a4ca 100644 --- a/course/src/test/java/org/openedx/course/presentation/outline/CourseOutlineViewModelTest.kt +++ b/course/src/test/java/org/openedx/course/presentation/outline/CourseOutlineViewModelTest.kt @@ -57,6 +57,7 @@ import org.openedx.core.system.notifier.CourseNotifier 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.CourseRouter import java.net.UnknownHostException import java.util.Date @@ -78,6 +79,7 @@ class CourseOutlineViewModelTest { private val workerController = mockk() private val analytics = mockk() private val coreAnalytics = mockk() + private val courseRouter = mockk() private val noInternet = "Slow or no internet connection" private val somethingWrong = "Something went wrong" @@ -247,6 +249,7 @@ class CourseOutlineViewModelTest { networkConnection, preferencesManager, analytics, + courseRouter, coreAnalytics, downloadDao, workerController, @@ -281,6 +284,7 @@ class CourseOutlineViewModelTest { networkConnection, preferencesManager, analytics, + courseRouter, coreAnalytics, downloadDao, workerController @@ -325,6 +329,7 @@ class CourseOutlineViewModelTest { networkConnection, preferencesManager, analytics, + courseRouter, coreAnalytics, downloadDao, workerController @@ -372,6 +377,7 @@ class CourseOutlineViewModelTest { networkConnection, preferencesManager, analytics, + courseRouter, coreAnalytics, downloadDao, workerController @@ -418,6 +424,7 @@ class CourseOutlineViewModelTest { networkConnection, preferencesManager, analytics, + courseRouter, coreAnalytics, downloadDao, workerController @@ -452,6 +459,7 @@ class CourseOutlineViewModelTest { networkConnection, preferencesManager, analytics, + courseRouter, coreAnalytics, downloadDao, workerController @@ -500,6 +508,7 @@ class CourseOutlineViewModelTest { networkConnection, preferencesManager, analytics, + courseRouter, coreAnalytics, downloadDao, workerController @@ -543,6 +552,7 @@ class CourseOutlineViewModelTest { networkConnection, preferencesManager, analytics, + courseRouter, coreAnalytics, downloadDao, workerController @@ -578,6 +588,7 @@ class CourseOutlineViewModelTest { networkConnection, preferencesManager, analytics, + courseRouter, coreAnalytics, downloadDao, workerController diff --git a/course/src/test/java/org/openedx/course/presentation/videos/CourseVideoViewModelTest.kt b/course/src/test/java/org/openedx/course/presentation/videos/CourseVideoViewModelTest.kt index aa403c893..25774f555 100644 --- a/course/src/test/java/org/openedx/course/presentation/videos/CourseVideoViewModelTest.kt +++ b/course/src/test/java/org/openedx/course/presentation/videos/CourseVideoViewModelTest.kt @@ -54,6 +54,7 @@ import org.openedx.core.system.notifier.VideoNotifier import org.openedx.course.R import org.openedx.course.domain.interactor.CourseInteractor import org.openedx.course.presentation.CourseAnalytics +import org.openedx.course.presentation.CourseRouter import java.util.Date @OptIn(ExperimentalCoroutinesApi::class) @@ -74,6 +75,7 @@ class CourseVideoViewModelTest { private val networkConnection = mockk() private val downloadDao = mockk() private val workerController = mockk() + private val courseRouter = mockk() private val cantDownload = "You can download content only from Wi-fi" @@ -211,9 +213,10 @@ class CourseVideoViewModelTest { courseNotifier, videoNotifier, analytics, + courseRouter, coreAnalytics, downloadDao, - workerController + workerController, ) viewModel.getVideos() @@ -242,6 +245,7 @@ class CourseVideoViewModelTest { courseNotifier, videoNotifier, analytics, + courseRouter, coreAnalytics, downloadDao, workerController @@ -281,6 +285,7 @@ class CourseVideoViewModelTest { courseNotifier, videoNotifier, analytics, + courseRouter, coreAnalytics, downloadDao, workerController @@ -322,6 +327,7 @@ class CourseVideoViewModelTest { courseNotifier, videoNotifier, analytics, + courseRouter, coreAnalytics, downloadDao, workerController @@ -358,6 +364,7 @@ class CourseVideoViewModelTest { courseNotifier, videoNotifier, analytics, + courseRouter, coreAnalytics, downloadDao, workerController @@ -398,6 +405,7 @@ class CourseVideoViewModelTest { courseNotifier, videoNotifier, analytics, + courseRouter, coreAnalytics, downloadDao, workerController diff --git a/dashboard/src/androidTest/java/org/openedx/dashboard/presentation/MyCoursesScreenTest.kt b/dashboard/src/androidTest/java/org/openedx/dashboard/presentation/MyCoursesScreenTest.kt index dbf15acd4..f3b6a5aee 100644 --- a/dashboard/src/androidTest/java/org/openedx/dashboard/presentation/MyCoursesScreenTest.kt +++ b/dashboard/src/androidTest/java/org/openedx/dashboard/presentation/MyCoursesScreenTest.kt @@ -68,7 +68,7 @@ class MyCoursesScreenTest { @Test fun dashboardScreenLoading() { composeTestRule.setContent { - MyCoursesScreen( + DashboardListView( windowSize = WindowSize(WindowType.Compact, WindowType.Compact), apiHostUrl = "http://localhost:8000", state = DashboardUIState.Courses(listOf(mockCourseEnrolled, mockCourseEnrolled)), @@ -101,7 +101,7 @@ class MyCoursesScreenTest { @Test fun dashboardScreenLoaded() { composeTestRule.setContent { - MyCoursesScreen( + DashboardListView( windowSize = WindowSize(WindowType.Compact, WindowType.Compact), apiHostUrl = "http://localhost:8000", state = DashboardUIState.Courses(listOf(mockCourseEnrolled, mockCourseEnrolled)), @@ -127,7 +127,7 @@ class MyCoursesScreenTest { @Test fun dashboardScreenRefreshing() { composeTestRule.setContent { - MyCoursesScreen( + DashboardListView( windowSize = WindowSize(WindowType.Compact, WindowType.Compact), apiHostUrl = "http://localhost:8000", state = DashboardUIState.Courses(listOf(mockCourseEnrolled, mockCourseEnrolled)), diff --git a/dashboard/src/main/java/org/openedx/DashboardNavigator.kt b/dashboard/src/main/java/org/openedx/DashboardNavigator.kt new file mode 100644 index 000000000..9e5f4c900 --- /dev/null +++ b/dashboard/src/main/java/org/openedx/DashboardNavigator.kt @@ -0,0 +1,17 @@ +package org.openedx + +import androidx.fragment.app.Fragment +import org.openedx.core.config.DashboardConfig +import org.openedx.dashboard.presentation.DashboardListFragment +import org.openedx.learn.presentation.LearnFragment + +class DashboardNavigator( + private val dashboardType: DashboardConfig.DashboardType, +) { + fun getDashboardFragment(): Fragment { + return when (dashboardType) { + DashboardConfig.DashboardType.GALLERY -> LearnFragment() + else -> DashboardListFragment() + } + } +} diff --git a/dashboard/src/main/java/org/openedx/DashboardUI.kt b/dashboard/src/main/java/org/openedx/DashboardUI.kt new file mode 100644 index 000000000..13a3f42d1 --- /dev/null +++ b/dashboard/src/main/java/org/openedx/DashboardUI.kt @@ -0,0 +1,49 @@ +package org.openedx + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material.Icon +import androidx.compose.material.MaterialTheme +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Lock +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import org.openedx.core.ui.theme.OpenEdXTheme +import org.openedx.core.ui.theme.appColors + +@Composable +fun Lock(modifier: Modifier = Modifier) { + Box( + modifier = modifier.fillMaxSize() + ) { + Icon( + modifier = Modifier + .size(32.dp) + .padding(top = 8.dp, end = 8.dp) + .background( + color = MaterialTheme.appColors.onPrimary.copy(0.5f), + shape = CircleShape + ) + .padding(4.dp) + .align(Alignment.TopEnd), + imageVector = Icons.Default.Lock, + contentDescription = null, + tint = MaterialTheme.appColors.onSurface + ) + } +} + +@Preview +@Composable +private fun LockPreview() { + OpenEdXTheme { + Lock() + } +} diff --git a/dashboard/src/main/java/org/openedx/courses/presentation/AllEnrolledCoursesAction.kt b/dashboard/src/main/java/org/openedx/courses/presentation/AllEnrolledCoursesAction.kt new file mode 100644 index 000000000..7655fd6a2 --- /dev/null +++ b/dashboard/src/main/java/org/openedx/courses/presentation/AllEnrolledCoursesAction.kt @@ -0,0 +1,14 @@ +package org.openedx.courses.presentation + +import org.openedx.core.domain.model.EnrolledCourse +import org.openedx.dashboard.domain.CourseStatusFilter + +interface AllEnrolledCoursesAction { + object Reload : AllEnrolledCoursesAction + object SwipeRefresh : AllEnrolledCoursesAction + object EndOfPage : AllEnrolledCoursesAction + object Back : AllEnrolledCoursesAction + object Search : AllEnrolledCoursesAction + data class OpenCourse(val enrolledCourse: EnrolledCourse) : AllEnrolledCoursesAction + data class FilterChange(val courseStatusFilter: CourseStatusFilter?) : AllEnrolledCoursesAction +} diff --git a/dashboard/src/main/java/org/openedx/courses/presentation/AllEnrolledCoursesFragment.kt b/dashboard/src/main/java/org/openedx/courses/presentation/AllEnrolledCoursesFragment.kt new file mode 100644 index 000000000..e59a73fde --- /dev/null +++ b/dashboard/src/main/java/org/openedx/courses/presentation/AllEnrolledCoursesFragment.kt @@ -0,0 +1,27 @@ +package org.openedx.courses.presentation + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.ViewGroup +import androidx.compose.ui.platform.ComposeView +import androidx.compose.ui.platform.ViewCompositionStrategy +import androidx.fragment.app.Fragment +import org.openedx.core.ui.theme.OpenEdXTheme + +class AllEnrolledCoursesFragment : Fragment() { + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle?, + ) = ComposeView(requireContext()).apply { + setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed) + setContent { + OpenEdXTheme { + AllEnrolledCoursesView( + fragmentManager = requireActivity().supportFragmentManager + ) + } + } + } +} diff --git a/dashboard/src/main/java/org/openedx/courses/presentation/AllEnrolledCoursesUIState.kt b/dashboard/src/main/java/org/openedx/courses/presentation/AllEnrolledCoursesUIState.kt new file mode 100644 index 000000000..2d7efb51b --- /dev/null +++ b/dashboard/src/main/java/org/openedx/courses/presentation/AllEnrolledCoursesUIState.kt @@ -0,0 +1,10 @@ +package org.openedx.courses.presentation + +import org.openedx.core.domain.model.EnrolledCourse + +data class AllEnrolledCoursesUIState( + val courses: List? = null, + val refreshing: Boolean = false, + val canLoadMore: Boolean = false, + val showProgress: Boolean = false, +) diff --git a/dashboard/src/main/java/org/openedx/courses/presentation/AllEnrolledCoursesView.kt b/dashboard/src/main/java/org/openedx/courses/presentation/AllEnrolledCoursesView.kt new file mode 100644 index 000000000..3392ed7bd --- /dev/null +++ b/dashboard/src/main/java/org/openedx/courses/presentation/AllEnrolledCoursesView.kt @@ -0,0 +1,639 @@ +package org.openedx.courses.presentation + +import android.content.res.Configuration +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.clickable +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.calculateEndPadding +import androidx.compose.foundation.layout.calculateStartPadding +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.navigationBarsPadding +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.lazy.grid.GridCells +import androidx.compose.foundation.lazy.grid.LazyVerticalGrid +import androidx.compose.foundation.lazy.grid.items +import androidx.compose.foundation.lazy.grid.rememberLazyGridState +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.pager.rememberPagerState +import androidx.compose.material.Card +import androidx.compose.material.CircularProgressIndicator +import androidx.compose.material.ExperimentalMaterialApi +import androidx.compose.material.Icon +import androidx.compose.material.IconButton +import androidx.compose.material.LinearProgressIndicator +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.filled.Search +import androidx.compose.material.pullrefresh.PullRefreshIndicator +import androidx.compose.material.pullrefresh.pullRefresh +import androidx.compose.material.pullrefresh.rememberPullRefreshState +import androidx.compose.material.rememberScaffoldState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.ExperimentalComposeUiApi +import androidx.compose.ui.Modifier +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalLayoutDirection +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.semantics.testTagsAsResourceId +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow +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 coil.compose.AsyncImage +import coil.request.ImageRequest +import org.koin.androidx.compose.koinViewModel +import org.openedx.Lock +import org.openedx.core.R +import org.openedx.core.UIMessage +import org.openedx.core.domain.model.Certificate +import org.openedx.core.domain.model.CourseAssignments +import org.openedx.core.domain.model.CourseSharingUtmParameters +import org.openedx.core.domain.model.CourseStatus +import org.openedx.core.domain.model.CoursewareAccess +import org.openedx.core.domain.model.EnrolledCourse +import org.openedx.core.domain.model.EnrolledCourseData +import org.openedx.core.domain.model.Progress +import org.openedx.core.ui.BackBtn +import org.openedx.core.ui.HandleUIMessage +import org.openedx.core.ui.OfflineModeDialog +import org.openedx.core.ui.RoundTabsBar +import org.openedx.core.ui.displayCutoutForLandscape +import org.openedx.core.ui.rememberWindowSize +import org.openedx.core.ui.shouldLoadMore +import org.openedx.core.ui.statusBarsInset +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.core.ui.windowSizeValue +import org.openedx.core.utils.TimeUtils +import org.openedx.dashboard.domain.CourseStatusFilter +import java.util.Date + +@Composable +fun AllEnrolledCoursesView( + fragmentManager: FragmentManager +) { + val viewModel: AllEnrolledCoursesViewModel = koinViewModel() + val uiState by viewModel.uiState.collectAsState() + val uiMessage by viewModel.uiMessage.collectAsState(null) + + AllEnrolledCoursesView( + apiHostUrl = viewModel.apiHostUrl, + state = uiState, + uiMessage = uiMessage, + hasInternetConnection = viewModel.hasInternetConnection, + onAction = { action -> + when (action) { + AllEnrolledCoursesAction.Reload -> { + viewModel.getCourses() + } + + AllEnrolledCoursesAction.SwipeRefresh -> { + viewModel.updateCourses() + } + + AllEnrolledCoursesAction.EndOfPage -> { + viewModel.fetchMore() + } + + AllEnrolledCoursesAction.Back -> { + fragmentManager.popBackStack() + } + + AllEnrolledCoursesAction.Search -> { + viewModel.navigateToCourseSearch(fragmentManager) + } + + is AllEnrolledCoursesAction.OpenCourse -> { + with(action.enrolledCourse) { + viewModel.navigateToCourseOutline( + fragmentManager, + course.id, + course.name, + mode + ) + } + } + + is AllEnrolledCoursesAction.FilterChange -> { + viewModel.getCourses(action.courseStatusFilter) + } + } + } + ) +} + +@OptIn(ExperimentalMaterialApi::class, ExperimentalComposeUiApi::class, ExperimentalFoundationApi::class) +@Composable +private fun AllEnrolledCoursesView( + apiHostUrl: String, + state: AllEnrolledCoursesUIState, + uiMessage: UIMessage?, + hasInternetConnection: Boolean, + onAction: (AllEnrolledCoursesAction) -> Unit +) { + val windowSize = rememberWindowSize() + val layoutDirection = LocalLayoutDirection.current + val scaffoldState = rememberScaffoldState() + val scrollState = rememberLazyGridState() + val columns = if (windowSize.isTablet) 3 else 2 + val pullRefreshState = rememberPullRefreshState( + refreshing = state.refreshing, + onRefresh = { onAction(AllEnrolledCoursesAction.SwipeRefresh) } + ) + val tabPagerState = rememberPagerState(pageCount = { + CourseStatusFilter.entries.size + }) + var isInternetConnectionShown by rememberSaveable { + mutableStateOf(false) + } + val firstVisibleIndex = remember { + mutableIntStateOf(scrollState.firstVisibleItemIndex) + } + + Scaffold( + scaffoldState = scaffoldState, + modifier = Modifier + .fillMaxSize() + .semantics { + testTagsAsResourceId = true + }, + backgroundColor = MaterialTheme.appColors.background + ) { paddingValues -> + val contentPaddings by remember(key1 = windowSize) { + mutableStateOf( + windowSize.windowSizeValue( + expanded = PaddingValues( + top = 16.dp, + bottom = 40.dp, + ), + compact = PaddingValues(horizontal = 16.dp, vertical = 16.dp) + ) + ) + } + + val roundTapBarPaddings by remember(key1 = windowSize) { + mutableStateOf( + windowSize.windowSizeValue( + expanded = PaddingValues(vertical = 6.dp), + compact = PaddingValues(horizontal = 16.dp, vertical = 6.dp) + ) + ) + } + + + val emptyStatePaddings by remember(key1 = windowSize) { + mutableStateOf( + windowSize.windowSizeValue( + expanded = Modifier.padding( + top = 32.dp, + bottom = 40.dp + ), + compact = Modifier.padding(horizontal = 24.dp, vertical = 24.dp) + ) + ) + } + + val contentWidth by remember(key1 = windowSize) { + mutableStateOf( + windowSize.windowSizeValue( + expanded = Modifier.widthIn(Dp.Unspecified, 650.dp), + compact = Modifier.fillMaxWidth(), + ) + ) + } + + HandleUIMessage(uiMessage = uiMessage, scaffoldState = scaffoldState) + Box( + modifier = Modifier + .fillMaxSize() + .padding(paddingValues), + contentAlignment = Alignment.TopCenter + ) { + Column( + modifier = Modifier + .statusBarsInset() + .displayCutoutForLandscape() + .then(contentWidth), + horizontalAlignment = Alignment.CenterHorizontally + ) { + BackBtn( + modifier = Modifier.align(Alignment.Start), + tint = MaterialTheme.appColors.textDark + ) { + onAction(AllEnrolledCoursesAction.Back) + } + + Surface( + color = MaterialTheme.appColors.background, + shape = MaterialTheme.appShapes.screenBackgroundShape + ) { + Box( + modifier = Modifier + .fillMaxWidth() + .navigationBarsPadding() + .pullRefresh(pullRefreshState), + ) { + Column( + modifier = Modifier + .fillMaxWidth(), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Header( + modifier = Modifier + .padding( + start = contentPaddings.calculateStartPadding(layoutDirection), + end = contentPaddings.calculateEndPadding(layoutDirection) + ), + onSearchClick = { + onAction(AllEnrolledCoursesAction.Search) + } + ) + RoundTabsBar( + modifier = Modifier.align(Alignment.Start), + items = CourseStatusFilter.entries, + contentPadding = roundTapBarPaddings, + rowState = rememberLazyListState(), + pagerState = tabPagerState, + onTabClicked = { + val newFilter = CourseStatusFilter.entries[it] + onAction(AllEnrolledCoursesAction.FilterChange(newFilter)) + } + ) + when { + state.showProgress -> { + Box( + modifier = Modifier + .fillMaxSize(), + contentAlignment = Alignment.Center + ) { + CircularProgressIndicator(color = MaterialTheme.appColors.primary) + } + } + + !state.courses.isNullOrEmpty() -> { + Box( + modifier = Modifier + .fillMaxSize() + .padding(contentPaddings), + contentAlignment = Alignment.Center + ) { + Column( + modifier = Modifier.fillMaxWidth(), + horizontalAlignment = Alignment.CenterHorizontally + ) { + LazyVerticalGrid( + modifier = Modifier + .fillMaxHeight(), + state = scrollState, + columns = GridCells.Fixed(columns), + verticalArrangement = Arrangement.spacedBy(12.dp), + horizontalArrangement = Arrangement.spacedBy(12.dp), + content = { + items(state.courses) { course -> + CourseItem( + course = course, + apiHostUrl = apiHostUrl, + onClick = { + onAction(AllEnrolledCoursesAction.OpenCourse(it)) + } + ) + } + item { + if (state.canLoadMore) { + Box( + modifier = Modifier + .fillMaxWidth() + .height(180.dp), + contentAlignment = Alignment.Center + ) { + CircularProgressIndicator( + color = MaterialTheme.appColors.primary + ) + } + } + } + } + ) + } + if (scrollState.shouldLoadMore(firstVisibleIndex, 4)) { + onAction(AllEnrolledCoursesAction.EndOfPage) + } + } + } + + state.courses?.isEmpty() == true -> { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center + ) { + Column( + modifier = Modifier + .fillMaxHeight() + .then(emptyStatePaddings) + ) { + EmptyState( + currentCourseStatus = CourseStatusFilter.entries[tabPagerState.currentPage] + ) + } + } + } + } + } + PullRefreshIndicator( + state.refreshing, + pullRefreshState, + Modifier.align(Alignment.TopCenter) + ) + + if (!isInternetConnectionShown && !hasInternetConnection) { + OfflineModeDialog( + Modifier + .fillMaxWidth() + .align(Alignment.BottomCenter), + onDismissCLick = { + isInternetConnectionShown = true + }, + onReloadClick = { + isInternetConnectionShown = true + onAction(AllEnrolledCoursesAction.Reload) + } + ) + } + } + } + } + } + } +} + +@Composable +fun CourseItem( + modifier: Modifier = Modifier, + course: EnrolledCourse, + apiHostUrl: String, + onClick: (EnrolledCourse) -> Unit, +) { + Card( + modifier = modifier + .width(170.dp) + .height(180.dp) + .clickable { + onClick(course) + }, + backgroundColor = MaterialTheme.appColors.background, + shape = MaterialTheme.appShapes.courseImageShape, + elevation = 4.dp + ) { + Box { + Column { + AsyncImage( + model = ImageRequest.Builder(LocalContext.current) + .data(apiHostUrl + course.course.courseImage) + .error(R.drawable.core_no_image_course) + .placeholder(R.drawable.core_no_image_course) + .build(), + contentDescription = null, + contentScale = ContentScale.Crop, + modifier = Modifier + .fillMaxWidth() + .height(90.dp) + ) + val progress: Float = try { + course.progress.assignmentsCompleted.toFloat() / course.progress.totalAssignmentsCount.toFloat() + } catch (_: ArithmeticException) { + 0f + } + LinearProgressIndicator( + modifier = Modifier + .fillMaxWidth() + .height(8.dp), + progress = progress, + color = MaterialTheme.appColors.primary, + backgroundColor = MaterialTheme.appColors.divider + ) + + Text( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 8.dp) + .padding(top = 4.dp), + style = MaterialTheme.appTypography.labelMedium, + color = MaterialTheme.appColors.textFieldHint, + overflow = TextOverflow.Ellipsis, + minLines = 1, + maxLines = 2, + text = stringResource( + org.openedx.dashboard.R.string.dashboard_course_date, + TimeUtils.getCourseFormattedDate( + LocalContext.current, + Date(), + course.auditAccessExpires, + course.course.start, + course.course.end, + course.course.startType, + course.course.startDisplay + ) + ) + ) + Text( + modifier = Modifier + .padding(horizontal = 8.dp, vertical = 4.dp), + text = course.course.name, + style = MaterialTheme.appTypography.titleSmall, + color = MaterialTheme.appColors.textDark, + overflow = TextOverflow.Ellipsis, + minLines = 1, + maxLines = 2 + ) + } + if (!course.course.coursewareAccess?.errorCode.isNullOrEmpty()) { + Lock() + } + } + } +} + +@Composable +fun Header( + modifier: Modifier = Modifier, + onSearchClick: () -> Unit +) { + Box( + modifier = modifier.fillMaxWidth() + ) { + Text( + modifier = Modifier.align(Alignment.CenterStart), + text = stringResource(id = org.openedx.dashboard.R.string.dashboard_all_courses), + color = MaterialTheme.appColors.textDark, + style = MaterialTheme.appTypography.headlineBold + ) + IconButton( + modifier = Modifier + .align(Alignment.CenterEnd) + .offset(x = 12.dp), + onClick = { + onSearchClick() + } + ) { + Icon( + imageVector = Icons.Filled.Search, + contentDescription = null, + tint = MaterialTheme.appColors.textDark + ) + } + } +} + +@Composable +fun EmptyState( + currentCourseStatus: CourseStatusFilter +) { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center + ) { + Column( + Modifier.width(200.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Icon( + painter = painterResource(id = org.openedx.dashboard.R.drawable.dashboard_ic_book), + tint = MaterialTheme.appColors.textFieldBorder, + contentDescription = null + ) + Spacer(Modifier.height(4.dp)) + Text( + modifier = Modifier + .testTag("txt_empty_state_title") + .fillMaxWidth(), + text = stringResource( + id = org.openedx.dashboard.R.string.dashboard_no_status_courses, + stringResource(currentCourseStatus.labelResId) + ), + color = MaterialTheme.appColors.textDark, + style = MaterialTheme.appTypography.titleMedium, + textAlign = TextAlign.Center + ) + } + } +} + +@Preview(uiMode = Configuration.UI_MODE_NIGHT_NO) +@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES) +@Composable +private fun CourseItemPreview() { + OpenEdXTheme { + CourseItem( + course = mockCourseEnrolled, + apiHostUrl = "", + onClick = {} + ) + } +} + +@Preview +@Composable +private fun EmptyStatePreview() { + OpenEdXTheme { + EmptyState( + currentCourseStatus = CourseStatusFilter.COMPLETE + ) + } +} + +@Preview(uiMode = Configuration.UI_MODE_NIGHT_NO) +@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES) +@Preview(uiMode = Configuration.UI_MODE_NIGHT_NO, device = Devices.NEXUS_9) +@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES, device = Devices.NEXUS_9) +@Composable +private fun AllEnrolledCoursesPreview() { + OpenEdXTheme { + AllEnrolledCoursesView( + apiHostUrl = "http://localhost:8000", + state = AllEnrolledCoursesUIState( + courses = listOf( + mockCourseEnrolled, + mockCourseEnrolled, + mockCourseEnrolled, + mockCourseEnrolled, + mockCourseEnrolled, + mockCourseEnrolled + ) + ), + uiMessage = null, + hasInternetConnection = true, + onAction = {} + ) + } +} + +private val mockCourseAssignments = CourseAssignments(null, emptyList()) +private val mockCourseEnrolled = EnrolledCourse( + auditAccessExpires = Date(), + created = "created", + certificate = Certificate(""), + mode = "mode", + isActive = true, + progress = Progress.DEFAULT_PROGRESS, + courseStatus = CourseStatus("", emptyList(), "", ""), + courseAssignments = mockCourseAssignments, + course = EnrolledCourseData( + id = "id", + name = "name", + number = "", + org = "Org", + start = Date(), + startDisplay = "", + startType = "", + end = Date(), + dynamicUpgradeDeadline = "", + subscriptionId = "", + coursewareAccess = CoursewareAccess( + false, + "204", + "", + "", + "", + "" + ), + media = null, + courseImage = "", + courseAbout = "", + courseSharingUtmParameters = CourseSharingUtmParameters("", ""), + courseUpdates = "", + courseHandouts = "", + discussionUrl = "", + videoOutline = "", + isSelfPaced = false + ) +) diff --git a/dashboard/src/main/java/org/openedx/courses/presentation/AllEnrolledCoursesViewModel.kt b/dashboard/src/main/java/org/openedx/courses/presentation/AllEnrolledCoursesViewModel.kt new file mode 100644 index 000000000..6f3f96ebf --- /dev/null +++ b/dashboard/src/main/java/org/openedx/courses/presentation/AllEnrolledCoursesViewModel.kt @@ -0,0 +1,181 @@ +package org.openedx.courses.presentation + +import androidx.fragment.app.FragmentManager +import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import org.openedx.core.BaseViewModel +import org.openedx.core.R +import org.openedx.core.UIMessage +import org.openedx.core.config.Config +import org.openedx.core.domain.model.EnrolledCourse +import org.openedx.core.extension.isInternetError +import org.openedx.core.system.ResourceManager +import org.openedx.core.system.connection.NetworkConnection +import org.openedx.core.system.notifier.CourseDashboardUpdate +import org.openedx.core.system.notifier.DiscoveryNotifier +import org.openedx.dashboard.domain.CourseStatusFilter +import org.openedx.dashboard.domain.interactor.DashboardInteractor +import org.openedx.dashboard.presentation.DashboardAnalytics +import org.openedx.dashboard.presentation.DashboardRouter + +class AllEnrolledCoursesViewModel( + private val config: Config, + private val networkConnection: NetworkConnection, + private val interactor: DashboardInteractor, + private val resourceManager: ResourceManager, + private val discoveryNotifier: DiscoveryNotifier, + private val analytics: DashboardAnalytics, + private val dashboardRouter: DashboardRouter +) : BaseViewModel() { + + val apiHostUrl get() = config.getApiHostURL() + val hasInternetConnection: Boolean + get() = networkConnection.isOnline() + + private val coursesList = mutableListOf() + private var page = 1 + private var isLoading = false + + private val _uiState = MutableStateFlow(AllEnrolledCoursesUIState()) + val uiState: StateFlow + get() = _uiState.asStateFlow() + + private val _uiMessage = MutableSharedFlow() + val uiMessage: SharedFlow + get() = _uiMessage.asSharedFlow() + + private val currentFilter: MutableStateFlow = MutableStateFlow(CourseStatusFilter.ALL) + + private var job: Job? = null + + init { + collectDiscoveryNotifier() + getCourses(currentFilter.value) + } + + fun getCourses(courseStatusFilter: CourseStatusFilter? = null) { + _uiState.update { it.copy(showProgress = true) } + coursesList.clear() + internalLoadingCourses(courseStatusFilter ?: currentFilter.value) + } + + fun updateCourses() { + viewModelScope.launch { + try { + _uiState.update { it.copy(refreshing = true) } + isLoading = true + page = 1 + val response = interactor.getAllUserCourses(page, currentFilter.value) + if (response.pagination.next.isNotEmpty() && page != response.pagination.numPages) { + _uiState.update { it.copy(canLoadMore = true) } + page++ + } else { + _uiState.update { it.copy(canLoadMore = false) } + page = -1 + } + coursesList.clear() + coursesList.addAll(response.courses) + _uiState.update { it.copy(courses = coursesList) } + } 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_error_unknown_error))) + } + } + _uiState.update { it.copy(refreshing = false, showProgress = false) } + isLoading = false + } + } + + private fun internalLoadingCourses(courseStatusFilter: CourseStatusFilter? = null) { + if (courseStatusFilter != null) { + page = 1 + currentFilter.value = courseStatusFilter + } + job?.cancel() + job = viewModelScope.launch { + try { + isLoading = true + val response = if (networkConnection.isOnline() || page > 1) { + interactor.getAllUserCourses(page, currentFilter.value) + } else { + null + } + if (response != null) { + if (response.pagination.next.isNotEmpty() && page != response.pagination.numPages) { + _uiState.update { it.copy(canLoadMore = true) } + page++ + } else { + _uiState.update { it.copy(canLoadMore = false) } + page = -1 + } + coursesList.addAll(response.courses) + } else { + val cachedList = interactor.getEnrolledCoursesFromCache() + _uiState.update { it.copy(canLoadMore = false) } + page = -1 + coursesList.addAll(cachedList) + } + _uiState.update { it.copy(courses = coursesList) } + } 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_error_unknown_error))) + } + } + _uiState.update { it.copy(refreshing = false, showProgress = false) } + isLoading = false + } + } + + fun fetchMore() { + if (!isLoading && page != -1) { + internalLoadingCourses() + } + } + + private fun dashboardCourseClickedEvent(courseId: String, courseName: String) { + analytics.dashboardCourseClickedEvent(courseId, courseName) + } + + private fun collectDiscoveryNotifier() { + viewModelScope.launch { + discoveryNotifier.notifier.collect { + if (it is CourseDashboardUpdate) { + updateCourses() + } + } + } + } + + fun navigateToCourseSearch(fragmentManager: FragmentManager) { + dashboardRouter.navigateToCourseSearch( + fragmentManager, "" + ) + } + + fun navigateToCourseOutline( + fragmentManager: FragmentManager, + courseId: String, + courseName: String, + mode: String + ) { + dashboardCourseClickedEvent(courseId, courseName) + dashboardRouter.navigateToCourseOutline( + fragmentManager, + courseId, + courseName, + mode + ) + } +} diff --git a/dashboard/src/main/java/org/openedx/courses/presentation/CourseTab.kt b/dashboard/src/main/java/org/openedx/courses/presentation/CourseTab.kt new file mode 100644 index 000000000..f0da7c186 --- /dev/null +++ b/dashboard/src/main/java/org/openedx/courses/presentation/CourseTab.kt @@ -0,0 +1,5 @@ +package org.openedx.courses.presentation + +enum class CourseTab { + HOME, VIDEOS, DATES, DISCUSSIONS, MORE +} diff --git a/dashboard/src/main/java/org/openedx/courses/presentation/DashboardGalleryFragment.kt b/dashboard/src/main/java/org/openedx/courses/presentation/DashboardGalleryFragment.kt new file mode 100644 index 000000000..b0309785c --- /dev/null +++ b/dashboard/src/main/java/org/openedx/courses/presentation/DashboardGalleryFragment.kt @@ -0,0 +1,24 @@ +package org.openedx.courses.presentation + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.ViewGroup +import androidx.compose.ui.platform.ComposeView +import androidx.compose.ui.platform.ViewCompositionStrategy +import androidx.fragment.app.Fragment +import org.openedx.core.ui.theme.OpenEdXTheme + +class DashboardGalleryFragment : Fragment() { + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle?, + ) = ComposeView(requireContext()).apply { + setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed) + setContent { + OpenEdXTheme { + DashboardGalleryView(fragmentManager = requireActivity().supportFragmentManager) + } + } + } +} diff --git a/dashboard/src/main/java/org/openedx/courses/presentation/DashboardGalleryScreenAction.kt b/dashboard/src/main/java/org/openedx/courses/presentation/DashboardGalleryScreenAction.kt new file mode 100644 index 000000000..f612a5289 --- /dev/null +++ b/dashboard/src/main/java/org/openedx/courses/presentation/DashboardGalleryScreenAction.kt @@ -0,0 +1,13 @@ +package org.openedx.courses.presentation + +import org.openedx.core.domain.model.EnrolledCourse + +interface DashboardGalleryScreenAction { + object SwipeRefresh : DashboardGalleryScreenAction + object ViewAll : DashboardGalleryScreenAction + object Reload : DashboardGalleryScreenAction + object NavigateToDiscovery : DashboardGalleryScreenAction + data class OpenBlock(val enrolledCourse: EnrolledCourse, val blockId: String) : DashboardGalleryScreenAction + data class OpenCourse(val enrolledCourse: EnrolledCourse) : DashboardGalleryScreenAction + data class NavigateToDates(val enrolledCourse: EnrolledCourse) : DashboardGalleryScreenAction +} diff --git a/dashboard/src/main/java/org/openedx/courses/presentation/DashboardGalleryUIState.kt b/dashboard/src/main/java/org/openedx/courses/presentation/DashboardGalleryUIState.kt new file mode 100644 index 000000000..c4049f463 --- /dev/null +++ b/dashboard/src/main/java/org/openedx/courses/presentation/DashboardGalleryUIState.kt @@ -0,0 +1,9 @@ +package org.openedx.courses.presentation + +import org.openedx.core.domain.model.CourseEnrollments + +sealed class DashboardGalleryUIState { + data class Courses(val userCourses: CourseEnrollments) : DashboardGalleryUIState() + data object Empty : DashboardGalleryUIState() + data object Loading : DashboardGalleryUIState() +} diff --git a/dashboard/src/main/java/org/openedx/courses/presentation/DashboardGalleryView.kt b/dashboard/src/main/java/org/openedx/courses/presentation/DashboardGalleryView.kt new file mode 100644 index 000000000..c4ea029b9 --- /dev/null +++ b/dashboard/src/main/java/org/openedx/courses/presentation/DashboardGalleryView.kt @@ -0,0 +1,863 @@ +package org.openedx.courses.presentation + +import android.content.res.Configuration +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.grid.GridCells +import androidx.compose.foundation.lazy.grid.LazyHorizontalGrid +import androidx.compose.foundation.lazy.grid.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 +import androidx.compose.material.ExperimentalMaterialApi +import androidx.compose.material.Icon +import androidx.compose.material.LinearProgressIndicator +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.ArrowForwardIos +import androidx.compose.material.icons.filled.ChevronRight +import androidx.compose.material.icons.filled.School +import androidx.compose.material.icons.filled.Warning +import androidx.compose.material.pullrefresh.PullRefreshIndicator +import androidx.compose.material.pullrefresh.pullRefresh +import androidx.compose.material.pullrefresh.rememberPullRefreshState +import androidx.compose.material.rememberScaffoldState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.painter.Painter +import androidx.compose.ui.graphics.vector.rememberVectorPainter +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.tooling.preview.Devices +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.fragment.app.FragmentManager +import coil.compose.AsyncImage +import coil.request.ImageRequest +import org.koin.androidx.compose.koinViewModel +import org.openedx.Lock +import org.openedx.core.UIMessage +import org.openedx.core.domain.model.AppConfig +import org.openedx.core.domain.model.Certificate +import org.openedx.core.domain.model.CourseAssignments +import org.openedx.core.domain.model.CourseDateBlock +import org.openedx.core.domain.model.CourseDatesCalendarSync +import org.openedx.core.domain.model.CourseEnrollments +import org.openedx.core.domain.model.CourseSharingUtmParameters +import org.openedx.core.domain.model.CourseStatus +import org.openedx.core.domain.model.CoursewareAccess +import org.openedx.core.domain.model.DashboardCourseList +import org.openedx.core.domain.model.EnrolledCourse +import org.openedx.core.domain.model.EnrolledCourseData +import org.openedx.core.domain.model.Pagination +import org.openedx.core.domain.model.Progress +import org.openedx.core.ui.HandleUIMessage +import org.openedx.core.ui.OfflineModeDialog +import org.openedx.core.ui.OpenEdXButton +import org.openedx.core.ui.TextIcon +import org.openedx.core.ui.rememberWindowSize +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.core.utils.TimeUtils +import org.openedx.dashboard.R +import java.util.Date +import org.openedx.core.R as CoreR + +@Composable +fun DashboardGalleryView( + fragmentManager: FragmentManager, +) { + val viewModel: DashboardGalleryViewModel = koinViewModel() + val updating by viewModel.updating.collectAsState(false) + val uiMessage by viewModel.uiMessage.collectAsState(null) + val uiState by viewModel.uiState.collectAsState(DashboardGalleryUIState.Loading) + + DashboardGalleryView( + uiMessage = uiMessage, + uiState = uiState, + updating = updating, + apiHostUrl = viewModel.apiHostUrl, + hasInternetConnection = viewModel.hasInternetConnection, + onAction = { action -> + when (action) { + DashboardGalleryScreenAction.SwipeRefresh -> { + viewModel.updateCourses() + } + + DashboardGalleryScreenAction.ViewAll -> { + viewModel.navigateToAllEnrolledCourses(fragmentManager) + } + + DashboardGalleryScreenAction.Reload -> { + viewModel.getCourses() + } + + DashboardGalleryScreenAction.NavigateToDiscovery -> { + viewModel.navigateToDiscovery() + } + + is DashboardGalleryScreenAction.OpenCourse -> { + viewModel.navigateToCourseOutline( + fragmentManager = fragmentManager, + enrolledCourse = action.enrolledCourse + ) + } + + is DashboardGalleryScreenAction.NavigateToDates -> { + viewModel.navigateToCourseOutline( + fragmentManager = fragmentManager, + enrolledCourse = action.enrolledCourse, + openDates = true + ) + } + + is DashboardGalleryScreenAction.OpenBlock -> { + viewModel.navigateToCourseOutline( + fragmentManager = fragmentManager, + enrolledCourse = action.enrolledCourse, + resumeBlockId = action.blockId + ) + } + } + } + ) +} + +@OptIn(ExperimentalMaterialApi::class) +@Composable +private fun DashboardGalleryView( + uiMessage: UIMessage?, + uiState: DashboardGalleryUIState, + updating: Boolean, + apiHostUrl: String, + onAction: (DashboardGalleryScreenAction) -> Unit, + hasInternetConnection: Boolean +) { + val scaffoldState = rememberScaffoldState() + val pullRefreshState = rememberPullRefreshState( + refreshing = updating, + onRefresh = { onAction(DashboardGalleryScreenAction.SwipeRefresh) } + ) + var isInternetConnectionShown by rememberSaveable { + mutableStateOf(false) + } + + Scaffold( + scaffoldState = scaffoldState, + modifier = Modifier.fillMaxSize(), + backgroundColor = MaterialTheme.appColors.background + ) { paddingValues -> + + HandleUIMessage(uiMessage = uiMessage, scaffoldState = scaffoldState) + + Surface( + modifier = Modifier + .fillMaxSize() + .padding(paddingValues), + color = MaterialTheme.appColors.background + ) { + Box( + Modifier.fillMaxSize() + ) { + Box( + Modifier + .fillMaxSize() + .pullRefresh(pullRefreshState) + .verticalScroll(rememberScrollState()), + ) { + when (uiState) { + is DashboardGalleryUIState.Loading -> { + CircularProgressIndicator( + modifier = Modifier.align(Alignment.Center), + color = MaterialTheme.appColors.primary + ) + } + + is DashboardGalleryUIState.Courses -> { + UserCourses( + modifier = Modifier.fillMaxSize(), + userCourses = uiState.userCourses, + apiHostUrl = apiHostUrl, + openCourse = { + onAction(DashboardGalleryScreenAction.OpenCourse(it)) + }, + onViewAllClick = { + onAction(DashboardGalleryScreenAction.ViewAll) + }, + navigateToDates = { + onAction(DashboardGalleryScreenAction.NavigateToDates(it)) + }, + resumeBlockId = { course, blockId -> + onAction(DashboardGalleryScreenAction.OpenBlock(course, blockId)) + } + ) + } + + is DashboardGalleryUIState.Empty -> { + NoCoursesInfo( + modifier = Modifier + .align(Alignment.Center) + ) + FindACourseButton( + modifier = Modifier + .align(Alignment.BottomCenter), + findACourseClick = { + onAction(DashboardGalleryScreenAction.NavigateToDiscovery) + } + ) + } + } + + PullRefreshIndicator( + updating, + pullRefreshState, + Modifier.align(Alignment.TopCenter) + ) + } + if (!isInternetConnectionShown && !hasInternetConnection) { + OfflineModeDialog( + Modifier + .fillMaxWidth() + .align(Alignment.BottomCenter), + onDismissCLick = { + isInternetConnectionShown = true + }, + onReloadClick = { + isInternetConnectionShown = true + onAction(DashboardGalleryScreenAction.SwipeRefresh) + } + ) + } + } + } + } +} + +@Composable +private fun UserCourses( + modifier: Modifier = Modifier, + userCourses: CourseEnrollments, + apiHostUrl: String, + openCourse: (EnrolledCourse) -> Unit, + navigateToDates: (EnrolledCourse) -> Unit, + onViewAllClick: () -> Unit, + resumeBlockId: (enrolledCourse: EnrolledCourse, blockId: String) -> Unit, +) { + Column( + modifier = modifier + .padding(vertical = 12.dp) + ) { + val primaryCourse = userCourses.primary + if (primaryCourse != null) { + PrimaryCourseCard( + primaryCourse = primaryCourse, + apiHostUrl = apiHostUrl, + navigateToDates = navigateToDates, + resumeBlockId = resumeBlockId, + openCourse = openCourse + ) + } + if (userCourses.enrollments.courses.isNotEmpty()) { + SecondaryCourses( + courses = userCourses.enrollments.courses, + apiHostUrl = apiHostUrl, + onCourseClick = openCourse, + onViewAllClick = onViewAllClick + ) + } + } +} + +@Composable +private fun SecondaryCourses( + courses: List, + apiHostUrl: String, + onCourseClick: (EnrolledCourse) -> Unit, + onViewAllClick: () -> Unit +) { + val windowSize = rememberWindowSize() + val itemsCount = if (windowSize.isTablet) 7 else 5 + val rows = if (windowSize.isTablet) 2 else 1 + val height = if (windowSize.isTablet) 322.dp else 152.dp + val items = courses.take(itemsCount) + Column( + modifier = Modifier + .fillMaxSize() + .padding(top = 12.dp), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + TextIcon( + modifier = Modifier.padding(horizontal = 18.dp), + text = stringResource(R.string.dashboard_view_all_with_count, courses.size + 1), + textStyle = MaterialTheme.appTypography.titleSmall, + icon = Icons.Default.ChevronRight, + color = MaterialTheme.appColors.textDark, + iconModifier = Modifier.size(22.dp), + onClick = onViewAllClick + ) + LazyHorizontalGrid( + modifier = Modifier + .fillMaxSize() + .height(height), + rows = GridCells.Fixed(rows), + contentPadding = PaddingValues(horizontal = 18.dp), + content = { + items(items) { + CourseListItem( + course = it, + apiHostUrl = apiHostUrl, + onCourseClick = onCourseClick + ) + } + item { + ViewAllItem( + onViewAllClick = onViewAllClick + ) + } + } + ) + } +} + +@Composable +private fun ViewAllItem( + onViewAllClick: () -> Unit +) { + Card( + modifier = Modifier + .width(140.dp) + .height(152.dp) + .padding(4.dp) + .clickable( + onClickLabel = stringResource(id = R.string.dashboard_view_all), + onClick = { + onViewAllClick() + } + ), + backgroundColor = MaterialTheme.appColors.cardViewBackground, + shape = MaterialTheme.appShapes.courseImageShape, + elevation = 4.dp, + ) { + Column( + modifier = Modifier.fillMaxSize(), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center + ) { + Icon( + modifier = Modifier.size(48.dp), + painter = painterResource(id = R.drawable.dashboard_ic_book), + tint = MaterialTheme.appColors.textFieldBorder, + contentDescription = null + ) + Spacer(modifier = Modifier.height(12.dp)) + Text( + text = stringResource(id = R.string.dashboard_view_all), + style = MaterialTheme.appTypography.titleSmall, + color = MaterialTheme.appColors.textDark + ) + } + } +} + +@Composable +private fun CourseListItem( + course: EnrolledCourse, + apiHostUrl: String, + onCourseClick: (EnrolledCourse) -> Unit, +) { + Card( + modifier = Modifier + .width(140.dp) + .height(152.dp) + .padding(4.dp) + .clickable { + onCourseClick(course) + }, + backgroundColor = MaterialTheme.appColors.background, + shape = MaterialTheme.appShapes.courseImageShape, + elevation = 4.dp + ) { + Box { + Column { + AsyncImage( + model = ImageRequest.Builder(LocalContext.current) + .data(apiHostUrl + course.course.courseImage) + .error(CoreR.drawable.core_no_image_course) + .placeholder(CoreR.drawable.core_no_image_course) + .build(), + contentDescription = null, + contentScale = ContentScale.Crop, + modifier = Modifier + .fillMaxWidth() + .height(90.dp) + ) + Text( + modifier = Modifier + .fillMaxHeight() + .padding(horizontal = 4.dp, vertical = 8.dp), + text = course.course.name, + style = MaterialTheme.appTypography.titleSmall, + color = MaterialTheme.appColors.textDark, + overflow = TextOverflow.Ellipsis, + maxLines = 2, + minLines = 2 + ) + } + if (!course.course.coursewareAccess?.errorCode.isNullOrEmpty()) { + Lock() + } + } + } +} + +@Composable +private fun AssignmentItem( + modifier: Modifier = Modifier, + painter: Painter, + title: String?, + info: String +) { + Row( + modifier = modifier + .fillMaxWidth() + .heightIn(min = 62.dp) + .padding(vertical = 12.dp, horizontal = 14.dp), + horizontalArrangement = Arrangement.spacedBy(12.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + painter = painter, + tint = MaterialTheme.appColors.textDark, + contentDescription = null + ) + Column( + modifier = Modifier.weight(1f), + verticalArrangement = Arrangement.spacedBy(4.dp) + ) { + val infoTextStyle = if (title.isNullOrEmpty()) { + MaterialTheme.appTypography.titleSmall + } else { + MaterialTheme.appTypography.labelSmall + } + Text( + text = info, + color = MaterialTheme.appColors.textDark, + style = infoTextStyle + ) + if (!title.isNullOrEmpty()) { + Text( + text = title, + color = MaterialTheme.appColors.textDark, + style = MaterialTheme.appTypography.titleSmall + ) + } + } + Icon( + modifier = Modifier.size(16.dp), + imageVector = Icons.AutoMirrored.Filled.ArrowForwardIos, + tint = MaterialTheme.appColors.textDark, + contentDescription = null + ) + } +} + +@Composable +private fun PrimaryCourseCard( + primaryCourse: EnrolledCourse, + apiHostUrl: String, + navigateToDates: (EnrolledCourse) -> Unit, + resumeBlockId: (enrolledCourse: EnrolledCourse, blockId: String) -> Unit, + openCourse: (EnrolledCourse) -> Unit, +) { + val context = LocalContext.current + Card( + modifier = Modifier + .padding(horizontal = 16.dp) + .fillMaxWidth() + .padding(2.dp), + backgroundColor = MaterialTheme.appColors.background, + shape = MaterialTheme.appShapes.courseImageShape, + elevation = 4.dp + ) { + Column( + modifier = Modifier + .clickable { + openCourse(primaryCourse) + } + ) { + AsyncImage( + model = ImageRequest.Builder(context) + .data(apiHostUrl + primaryCourse.course.courseImage) + .error(CoreR.drawable.core_no_image_course) + .placeholder(CoreR.drawable.core_no_image_course) + .build(), + contentDescription = null, + contentScale = ContentScale.Crop, + modifier = Modifier + .fillMaxWidth() + .height(140.dp) + ) + val progress: Float = try { + primaryCourse.progress.assignmentsCompleted.toFloat() / primaryCourse.progress.totalAssignmentsCount.toFloat() + } catch (_: ArithmeticException) { + 0f + } + LinearProgressIndicator( + modifier = Modifier + .fillMaxWidth() + .height(8.dp), + progress = progress, + color = MaterialTheme.appColors.primary, + backgroundColor = MaterialTheme.appColors.divider + ) + PrimaryCourseTitle( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 12.dp) + .padding(top = 8.dp, bottom = 16.dp), + primaryCourse = primaryCourse + ) + val pastAssignments = primaryCourse.courseAssignments?.pastAssignments + if (!pastAssignments.isNullOrEmpty()) { + val nearestAssignment = pastAssignments.maxBy { it.date } + val title = if (pastAssignments.size == 1) nearestAssignment.title else null + Divider() + AssignmentItem( + modifier = Modifier.clickable { + if (pastAssignments.size == 1) { + resumeBlockId(primaryCourse, nearestAssignment.blockId) + } else { + navigateToDates(primaryCourse) + } + }, + painter = rememberVectorPainter(Icons.Default.Warning), + title = title, + info = stringResource(R.string.dashboard_past_due_assignment, pastAssignments.size) + ) + } + val futureAssignments = primaryCourse.courseAssignments?.futureAssignments + if (!futureAssignments.isNullOrEmpty()) { + val nearestAssignment = futureAssignments.minBy { it.date } + val title = if (futureAssignments.size == 1) nearestAssignment.title else null + Divider() + AssignmentItem( + modifier = Modifier.clickable { + if (futureAssignments.size == 1) { + resumeBlockId(primaryCourse, nearestAssignment.blockId) + } else { + navigateToDates(primaryCourse) + } + }, + painter = painterResource(id = CoreR.drawable.ic_core_chapter_icon), + title = title, + info = stringResource( + R.string.dashboard_assignment_due_in_days, + nearestAssignment.assignmentType ?: "", + TimeUtils.getCourseFormattedDate(context, nearestAssignment.date) + ) + ) + } + ResumeButton( + primaryCourse = primaryCourse, + onClick = { + if (primaryCourse.courseStatus == null) { + openCourse(primaryCourse) + } else { + resumeBlockId(primaryCourse, primaryCourse.courseStatus?.lastVisitedBlockId ?: "") + } + } + ) + } + } +} + +@Composable +private fun ResumeButton( + modifier: Modifier = Modifier, + primaryCourse: EnrolledCourse, + onClick: () -> Unit +) { + Row( + modifier = modifier + .fillMaxWidth() + .clickable { onClick() } + .heightIn(min = 60.dp) + .background(MaterialTheme.appColors.primary) + .padding(12.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp) + ) { + if (primaryCourse.courseStatus == null) { + Icon( + imageVector = Icons.Default.School, + tint = MaterialTheme.appColors.primaryButtonText, + contentDescription = null + ) + Text( + modifier = Modifier.weight(1f), + text = stringResource(R.string.dashboard_start_course), + color = MaterialTheme.appColors.primaryButtonText, + style = MaterialTheme.appTypography.titleSmall + ) + } else { + Icon( + imageVector = Icons.Default.School, + tint = MaterialTheme.appColors.primaryButtonText, + contentDescription = null + ) + Column( + modifier = Modifier.weight(1f), + verticalArrangement = Arrangement.spacedBy(2.dp) + ) { + Text( + text = stringResource(R.string.dashboard_resume_course), + color = MaterialTheme.appColors.primaryButtonText, + style = MaterialTheme.appTypography.labelSmall + ) + Text( + text = primaryCourse.courseStatus?.lastVisitedUnitDisplayName ?: "", + color = MaterialTheme.appColors.primaryButtonText, + style = MaterialTheme.appTypography.titleSmall + ) + } + } + Icon( + modifier = Modifier.size(16.dp), + imageVector = Icons.AutoMirrored.Filled.ArrowForwardIos, + tint = MaterialTheme.appColors.primaryButtonText, + contentDescription = null + ) + } +} + +@Composable +private fun PrimaryCourseTitle( + modifier: Modifier = Modifier, + primaryCourse: EnrolledCourse +) { + Column( + modifier = modifier, + verticalArrangement = Arrangement.spacedBy(4.dp) + ) { + Text( + modifier = Modifier.fillMaxWidth(), + text = primaryCourse.course.org, + style = MaterialTheme.appTypography.labelMedium, + color = MaterialTheme.appColors.textFieldHint + ) + Text( + modifier = Modifier.fillMaxWidth(), + text = primaryCourse.course.name, + style = MaterialTheme.appTypography.titleLarge, + color = MaterialTheme.appColors.textDark, + overflow = TextOverflow.Ellipsis, + maxLines = 3 + ) + Text( + modifier = Modifier.fillMaxWidth(), + style = MaterialTheme.appTypography.labelMedium, + color = MaterialTheme.appColors.textFieldHint, + text = stringResource( + R.string.dashboard_course_date, + TimeUtils.getCourseFormattedDate( + LocalContext.current, + Date(), + primaryCourse.auditAccessExpires, + primaryCourse.course.start, + primaryCourse.course.end, + primaryCourse.course.startType, + primaryCourse.course.startDisplay + ) + ) + ) + } +} + +@Composable +private fun FindACourseButton( + modifier: Modifier = Modifier, + findACourseClick: () -> Unit +) { + OpenEdXButton( + modifier = modifier + .fillMaxWidth() + .padding(horizontal = 8.dp, vertical = 20.dp), + onClick = { + findACourseClick() + } + ) { + Text( + color = MaterialTheme.appColors.primaryButtonText, + text = stringResource(id = R.string.dashboard_find_a_course) + ) + } +} + +@Composable +private fun NoCoursesInfo( + modifier: Modifier = Modifier +) { + Box( + modifier = modifier.fillMaxSize(), + contentAlignment = Alignment.Center + ) { + Column( + modifier = Modifier.width(200.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Icon( + painter = painterResource(id = R.drawable.dashboard_ic_book), + tint = MaterialTheme.appColors.textFieldBorder, + contentDescription = null + ) + Spacer(Modifier.height(4.dp)) + Text( + modifier = Modifier + .testTag("txt_empty_state_title") + .fillMaxWidth(), + text = stringResource(id = R.string.dashboard_all_courses_empty_title), + color = MaterialTheme.appColors.textDark, + style = MaterialTheme.appTypography.titleMedium, + textAlign = TextAlign.Center + ) + Spacer(Modifier.height(12.dp)) + Text( + modifier = Modifier + .testTag("txt_empty_state_description") + .fillMaxWidth(), + text = stringResource(id = R.string.dashboard_all_courses_empty_description), + color = MaterialTheme.appColors.textDark, + style = MaterialTheme.appTypography.labelMedium, + textAlign = TextAlign.Center + ) + } + } +} + +private val mockCourseDateBlock = CourseDateBlock( + title = "Homework 1: ABCD", + description = "After this date, course content will be archived", + date = TimeUtils.iso8601ToDate("2023-10-20T15:08:07Z")!!, + assignmentType = "Homework" +) +private val mockCourseAssignments = + CourseAssignments(listOf(mockCourseDateBlock), listOf(mockCourseDateBlock, mockCourseDateBlock)) +private val mockCourse = EnrolledCourse( + auditAccessExpires = Date(), + created = "created", + certificate = Certificate(""), + mode = "mode", + isActive = true, + progress = Progress.DEFAULT_PROGRESS, + courseStatus = CourseStatus("", emptyList(), "", "Unit name"), + courseAssignments = mockCourseAssignments, + course = EnrolledCourseData( + id = "id", + name = "Looooooooooooooooooooong Course name", + number = "", + org = "Org", + start = Date(), + startDisplay = "", + startType = "", + end = Date(), + dynamicUpgradeDeadline = "", + subscriptionId = "", + coursewareAccess = CoursewareAccess( + true, + "", + "", + "", + "", + "", + ), + media = null, + courseImage = "", + courseAbout = "", + courseSharingUtmParameters = CourseSharingUtmParameters("", ""), + courseUpdates = "", + courseHandouts = "", + discussionUrl = "", + videoOutline = "", + isSelfPaced = false + ) +) +private val mockPagination = Pagination(10, "", 4, "1") +private val mockDashboardCourseList = DashboardCourseList( + pagination = mockPagination, + courses = listOf(mockCourse, mockCourse, mockCourse, mockCourse, mockCourse, mockCourse) +) + +private val mockUserCourses = CourseEnrollments( + enrollments = mockDashboardCourseList, + configs = AppConfig(CourseDatesCalendarSync(true, true, true, true)), + primary = mockCourse +) + +@Preview(uiMode = Configuration.UI_MODE_NIGHT_NO) +@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES) +@Composable +private fun ViewAllItemPreview() { + OpenEdXTheme { + ViewAllItem( + onViewAllClick = {} + ) + } +} + +@Preview(uiMode = Configuration.UI_MODE_NIGHT_NO) +@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES) +@Preview(uiMode = Configuration.UI_MODE_NIGHT_NO, device = Devices.NEXUS_9) +@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES, device = Devices.NEXUS_9) +@Composable +private fun DashboardGalleryViewPreview() { + OpenEdXTheme { + DashboardGalleryView( + uiState = DashboardGalleryUIState.Courses(mockUserCourses), + apiHostUrl = "", + uiMessage = null, + updating = false, + hasInternetConnection = false, + onAction = {} + ) + } +} + +@Preview +@Composable +private fun NoCoursesInfoPreview() { + OpenEdXTheme { + NoCoursesInfo() + } +} diff --git a/dashboard/src/main/java/org/openedx/courses/presentation/DashboardGalleryViewModel.kt b/dashboard/src/main/java/org/openedx/courses/presentation/DashboardGalleryViewModel.kt new file mode 100644 index 000000000..6ff7ba3fd --- /dev/null +++ b/dashboard/src/main/java/org/openedx/courses/presentation/DashboardGalleryViewModel.kt @@ -0,0 +1,130 @@ +package org.openedx.courses.presentation + +import androidx.fragment.app.FragmentManager +import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch +import org.openedx.core.BaseViewModel +import org.openedx.core.R +import org.openedx.core.UIMessage +import org.openedx.core.config.Config +import org.openedx.core.data.model.CourseEnrollments +import org.openedx.core.domain.model.EnrolledCourse +import org.openedx.core.extension.isInternetError +import org.openedx.core.system.ResourceManager +import org.openedx.core.system.connection.NetworkConnection +import org.openedx.core.system.notifier.CourseDashboardUpdate +import org.openedx.core.system.notifier.DiscoveryNotifier +import org.openedx.core.system.notifier.NavigationToDiscovery +import org.openedx.core.utils.FileUtil +import org.openedx.dashboard.domain.interactor.DashboardInteractor +import org.openedx.dashboard.presentation.DashboardRouter + +class DashboardGalleryViewModel( + private val config: Config, + private val interactor: DashboardInteractor, + private val resourceManager: ResourceManager, + private val discoveryNotifier: DiscoveryNotifier, + private val networkConnection: NetworkConnection, + private val fileUtil: FileUtil, + private val dashboardRouter: DashboardRouter, +) : BaseViewModel() { + + val apiHostUrl get() = config.getApiHostURL() + + private val _uiState = + MutableStateFlow(DashboardGalleryUIState.Loading) + val uiState: StateFlow + get() = _uiState.asStateFlow() + + private val _uiMessage = MutableSharedFlow() + val uiMessage: SharedFlow + get() = _uiMessage.asSharedFlow() + + private val _updating = MutableStateFlow(false) + val updating: StateFlow + get() = _updating.asStateFlow() + + val hasInternetConnection: Boolean + get() = networkConnection.isOnline() + + init { + collectDiscoveryNotifier() + getCourses() + } + + fun getCourses() { + viewModelScope.launch { + try { + if (networkConnection.isOnline()) { + val response = interactor.getMainUserCourses() + if (response.primary == null && response.enrollments.courses.isEmpty()) { + _uiState.value = DashboardGalleryUIState.Empty + } else { + _uiState.value = DashboardGalleryUIState.Courses(response) + } + } else { + val courseEnrollments = fileUtil.getObjectFromFile() + if (courseEnrollments == null) { + _uiState.value = DashboardGalleryUIState.Empty + } else { + _uiState.value = + DashboardGalleryUIState.Courses(courseEnrollments.mapToDomain()) + } + } + } 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_error_unknown_error))) + } + } finally { + _updating.value = false + } + } + } + + fun updateCourses() { + _updating.value = true + getCourses() + } + + fun navigateToDiscovery() { + viewModelScope.launch { discoveryNotifier.send(NavigationToDiscovery()) } + } + + fun navigateToAllEnrolledCourses(fragmentManager: FragmentManager) { + dashboardRouter.navigateToAllEnrolledCourses(fragmentManager) + } + + fun navigateToCourseOutline( + fragmentManager: FragmentManager, + enrolledCourse: EnrolledCourse, + openDates: Boolean = false, + resumeBlockId: String = "", + ) { + dashboardRouter.navigateToCourseOutline( + fm = fragmentManager, + courseId = enrolledCourse.course.id, + courseTitle = enrolledCourse.course.name, + enrollmentMode = enrolledCourse.mode, + openTab = if (openDates) CourseTab.DATES.name else CourseTab.HOME.name, + resumeBlockId = resumeBlockId + ) + } + + private fun collectDiscoveryNotifier() { + viewModelScope.launch { + discoveryNotifier.notifier.collect { + if (it is CourseDashboardUpdate) { + updateCourses() + } + } + } + } +} diff --git a/dashboard/src/main/java/org/openedx/dashboard/data/repository/DashboardRepository.kt b/dashboard/src/main/java/org/openedx/dashboard/data/repository/DashboardRepository.kt index c85390fa1..22637f48c 100644 --- a/dashboard/src/main/java/org/openedx/dashboard/data/repository/DashboardRepository.kt +++ b/dashboard/src/main/java/org/openedx/dashboard/data/repository/DashboardRepository.kt @@ -2,14 +2,18 @@ package org.openedx.dashboard.data.repository import org.openedx.core.data.api.CourseApi import org.openedx.core.data.storage.CorePreferences +import org.openedx.core.domain.model.CourseEnrollments import org.openedx.core.domain.model.DashboardCourseList import org.openedx.core.domain.model.EnrolledCourse +import org.openedx.core.utils.FileUtil import org.openedx.dashboard.data.DashboardDao +import org.openedx.dashboard.domain.CourseStatusFilter class DashboardRepository( private val api: CourseApi, private val dao: DashboardDao, - private val preferencesManager: CorePreferences + private val preferencesManager: CorePreferences, + private val fileUtil: FileUtil, ) { suspend fun getEnrolledCourses(page: Int): DashboardCourseList { @@ -30,4 +34,30 @@ class DashboardRepository( val list = dao.readAllData() return list.map { it.mapToDomain() } } + + suspend fun getMainUserCourses(): CourseEnrollments { + val result = api.getUserCourses( + username = preferencesManager.user?.username ?: "", + ) + preferencesManager.appConfig = result.configs.mapToDomain() + + fileUtil.saveObjectToFile(result) + return result.mapToDomain() + } + + suspend fun getAllUserCourses(page: Int, status: CourseStatusFilter?): DashboardCourseList { + val user = preferencesManager.user + val result = api.getUserCourses( + username = user?.username ?: "", + page = page, + status = status?.key, + fields = listOf("course_progress") + ) + preferencesManager.appConfig = result.configs.mapToDomain() + + dao.clearCachedData() + dao.insertEnrolledCourseEntity(*result.enrollments.results.map { it.mapToRoomEntity() } + .toTypedArray()) + return result.enrollments.mapToDomain() + } } diff --git a/dashboard/src/main/java/org/openedx/dashboard/domain/CourseStatusFilter.kt b/dashboard/src/main/java/org/openedx/dashboard/domain/CourseStatusFilter.kt new file mode 100644 index 000000000..79a19b89d --- /dev/null +++ b/dashboard/src/main/java/org/openedx/dashboard/domain/CourseStatusFilter.kt @@ -0,0 +1,18 @@ +package org.openedx.dashboard.domain + +import androidx.annotation.StringRes +import androidx.compose.ui.graphics.vector.ImageVector +import org.openedx.core.ui.TabItem +import org.openedx.dashboard.R + +enum class CourseStatusFilter( + val key: String, + @StringRes + override val labelResId: Int, + override val icon: ImageVector? = null, +) : TabItem { + ALL("all", R.string.dashboard_course_filter_all), + IN_PROGRESS("in_progress", R.string.dashboard_course_filter_in_progress), + COMPLETE("completed", R.string.dashboard_course_filter_completed), + EXPIRED("expired", R.string.dashboard_course_filter_expired) +} diff --git a/dashboard/src/main/java/org/openedx/dashboard/domain/interactor/DashboardInteractor.kt b/dashboard/src/main/java/org/openedx/dashboard/domain/interactor/DashboardInteractor.kt index a29c2cc7e..ae2e94d93 100644 --- a/dashboard/src/main/java/org/openedx/dashboard/domain/interactor/DashboardInteractor.kt +++ b/dashboard/src/main/java/org/openedx/dashboard/domain/interactor/DashboardInteractor.kt @@ -2,9 +2,10 @@ package org.openedx.dashboard.domain.interactor import org.openedx.core.domain.model.DashboardCourseList import org.openedx.dashboard.data.repository.DashboardRepository +import org.openedx.dashboard.domain.CourseStatusFilter class DashboardInteractor( - private val repository: DashboardRepository + private val repository: DashboardRepository, ) { suspend fun getEnrolledCourses(page: Int): DashboardCourseList { @@ -12,4 +13,16 @@ class DashboardInteractor( } suspend fun getEnrolledCoursesFromCache() = repository.getEnrolledCoursesFromCache() -} \ No newline at end of file + + suspend fun getMainUserCourses() = repository.getMainUserCourses() + + suspend fun getAllUserCourses( + page: Int = 1, + status: CourseStatusFilter? = null, + ): DashboardCourseList { + return repository.getAllUserCourses( + page, + status + ) + } +} diff --git a/dashboard/src/main/java/org/openedx/dashboard/presentation/DashboardFragment.kt b/dashboard/src/main/java/org/openedx/dashboard/presentation/DashboardListFragment.kt similarity index 97% rename from dashboard/src/main/java/org/openedx/dashboard/presentation/DashboardFragment.kt rename to dashboard/src/main/java/org/openedx/dashboard/presentation/DashboardListFragment.kt index f6bc5c56a..597958e51 100644 --- a/dashboard/src/main/java/org/openedx/dashboard/presentation/DashboardFragment.kt +++ b/dashboard/src/main/java/org/openedx/dashboard/presentation/DashboardListFragment.kt @@ -73,10 +73,13 @@ import org.koin.androidx.viewmodel.ext.android.viewModel import org.openedx.core.AppUpdateState import org.openedx.core.UIMessage import org.openedx.core.domain.model.Certificate +import org.openedx.core.domain.model.CourseAssignments import org.openedx.core.domain.model.CourseSharingUtmParameters +import org.openedx.core.domain.model.CourseStatus import org.openedx.core.domain.model.CoursewareAccess import org.openedx.core.domain.model.EnrolledCourse import org.openedx.core.domain.model.EnrolledCourseData +import org.openedx.core.domain.model.Progress import org.openedx.core.presentation.global.app_upgrade.AppUpgradeRecommendedBox import org.openedx.core.system.notifier.AppUpgradeEvent import org.openedx.core.ui.HandleUIMessage @@ -98,9 +101,9 @@ import org.openedx.dashboard.R import java.util.Date import org.openedx.core.R as CoreR -class DashboardFragment : Fragment() { +class DashboardListFragment : Fragment() { - private val viewModel by viewModel() + private val viewModel by viewModel() private val router by inject() override fun onCreate(savedInstanceState: Bundle?) { @@ -123,7 +126,7 @@ class DashboardFragment : Fragment() { val canLoadMore by viewModel.canLoadMore.observeAsState(false) val appUpgradeEvent by viewModel.appUpgradeEvent.observeAsState() - MyCoursesScreen( + DashboardListView( windowSize = windowSize, viewModel.apiHostUrl, uiState!!, @@ -166,7 +169,7 @@ class DashboardFragment : Fragment() { @OptIn(ExperimentalMaterialApi::class, ExperimentalComposeUiApi::class) @Composable -internal fun MyCoursesScreen( +internal fun DashboardListView( windowSize: WindowSize, apiHostUrl: String, state: DashboardUIState, @@ -551,9 +554,9 @@ private fun CourseItemPreview() { @Preview(uiMode = UI_MODE_NIGHT_NO) @Preview(uiMode = UI_MODE_NIGHT_YES) @Composable -private fun MyCoursesScreenDay() { +private fun DashboardListViewPreview() { OpenEdXTheme { - MyCoursesScreen( + DashboardListView( windowSize = WindowSize(WindowType.Compact, WindowType.Compact), apiHostUrl = "http://localhost:8000", state = DashboardUIState.Courses( @@ -583,9 +586,9 @@ private fun MyCoursesScreenDay() { @Preview(uiMode = UI_MODE_NIGHT_NO, device = Devices.NEXUS_9) @Preview(uiMode = UI_MODE_NIGHT_YES, device = Devices.NEXUS_9) @Composable -private fun MyCoursesScreenTabletPreview() { +private fun DashboardListViewTabletPreview() { OpenEdXTheme { - MyCoursesScreen( + DashboardListView( windowSize = WindowSize(WindowType.Medium, WindowType.Medium), apiHostUrl = "http://localhost:8000", state = DashboardUIState.Courses( @@ -612,12 +615,16 @@ private fun MyCoursesScreenTabletPreview() { } } +private val mockCourseAssignments = CourseAssignments(null, emptyList()) private val mockCourseEnrolled = EnrolledCourse( auditAccessExpires = Date(), created = "created", certificate = Certificate(""), mode = "mode", isActive = true, + progress = Progress.DEFAULT_PROGRESS, + courseStatus = CourseStatus("", emptyList(), "", ""), + courseAssignments = mockCourseAssignments, course = EnrolledCourseData( id = "id", name = "name", diff --git a/dashboard/src/main/java/org/openedx/dashboard/presentation/DashboardViewModel.kt b/dashboard/src/main/java/org/openedx/dashboard/presentation/DashboardListViewModel.kt similarity index 99% rename from dashboard/src/main/java/org/openedx/dashboard/presentation/DashboardViewModel.kt rename to dashboard/src/main/java/org/openedx/dashboard/presentation/DashboardListViewModel.kt index 0ec06a2c3..812e52f2e 100644 --- a/dashboard/src/main/java/org/openedx/dashboard/presentation/DashboardViewModel.kt +++ b/dashboard/src/main/java/org/openedx/dashboard/presentation/DashboardListViewModel.kt @@ -20,8 +20,7 @@ import org.openedx.core.system.notifier.CourseDashboardUpdate import org.openedx.core.system.notifier.DiscoveryNotifier import org.openedx.dashboard.domain.interactor.DashboardInteractor - -class DashboardViewModel( +class DashboardListViewModel( private val config: Config, private val networkConnection: NetworkConnection, private val interactor: DashboardInteractor, diff --git a/dashboard/src/main/java/org/openedx/dashboard/presentation/DashboardRouter.kt b/dashboard/src/main/java/org/openedx/dashboard/presentation/DashboardRouter.kt index b0b0740d3..4d9b5cdbc 100644 --- a/dashboard/src/main/java/org/openedx/dashboard/presentation/DashboardRouter.kt +++ b/dashboard/src/main/java/org/openedx/dashboard/presentation/DashboardRouter.kt @@ -1,5 +1,6 @@ package org.openedx.dashboard.presentation +import androidx.fragment.app.Fragment import androidx.fragment.app.FragmentManager interface DashboardRouter { @@ -9,7 +10,15 @@ interface DashboardRouter { courseId: String, courseTitle: String, enrollmentMode: String, + openTab: String = "", + resumeBlockId: String = "" ) fun navigateToSettings(fm: FragmentManager) + + fun navigateToCourseSearch(fm: FragmentManager, querySearch: String) + + fun navigateToAllEnrolledCourses(fm: FragmentManager) + + fun getProgramFragmentInstance(): Fragment } diff --git a/dashboard/src/main/java/org/openedx/learn/LearnType.kt b/dashboard/src/main/java/org/openedx/learn/LearnType.kt new file mode 100644 index 000000000..08100ef35 --- /dev/null +++ b/dashboard/src/main/java/org/openedx/learn/LearnType.kt @@ -0,0 +1,9 @@ +package org.openedx.learn + +import androidx.annotation.StringRes +import org.openedx.dashboard.R + +enum class LearnType(@StringRes val title: Int) { + COURSES(R.string.dashboard_courses), + PROGRAMS(R.string.dashboard_programs) +} diff --git a/dashboard/src/main/java/org/openedx/learn/presentation/LearnFragment.kt b/dashboard/src/main/java/org/openedx/learn/presentation/LearnFragment.kt new file mode 100644 index 000000000..b2de66cd4 --- /dev/null +++ b/dashboard/src/main/java/org/openedx/learn/presentation/LearnFragment.kt @@ -0,0 +1,274 @@ +package org.openedx.learn.presentation + +import android.os.Bundle +import android.view.View +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.DropdownMenu +import androidx.compose.material.DropdownMenuItem +import androidx.compose.material.Icon +import androidx.compose.material.IconButton +import androidx.compose.material.MaterialTheme +import androidx.compose.material.Text +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.ExpandMore +import androidx.compose.material.icons.filled.ManageAccounts +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.rotate +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext +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 androidx.fragment.app.Fragment +import androidx.fragment.app.FragmentManager +import androidx.viewpager2.widget.ViewPager2 +import org.koin.android.ext.android.inject +import org.koin.androidx.compose.koinViewModel +import org.openedx.core.adapter.NavigationFragmentAdapter +import org.openedx.core.presentation.global.viewBinding +import org.openedx.core.ui.crop +import org.openedx.core.ui.displayCutoutForLandscape +import org.openedx.core.ui.rememberWindowSize +import org.openedx.core.ui.statusBarsInset +import org.openedx.core.ui.theme.OpenEdXTheme +import org.openedx.core.ui.theme.appColors +import org.openedx.core.ui.theme.appTypography +import org.openedx.core.ui.windowSizeValue +import org.openedx.courses.presentation.DashboardGalleryFragment +import org.openedx.dashboard.R +import org.openedx.dashboard.databinding.FragmentLearnBinding +import org.openedx.dashboard.presentation.DashboardRouter +import org.openedx.learn.LearnType +import org.openedx.core.R as CoreR + +class LearnFragment : Fragment(R.layout.fragment_learn) { + + private val binding by viewBinding(FragmentLearnBinding::bind) + private val router by inject() + private lateinit var adapter: NavigationFragmentAdapter + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + binding.header.setContent { + OpenEdXTheme { + Header( + fragmentManager = requireParentFragment().parentFragmentManager, + viewPager = binding.viewPager + ) + } + } + initViewPager() + } + + private fun initViewPager() { + binding.viewPager.orientation = ViewPager2.ORIENTATION_HORIZONTAL + binding.viewPager.offscreenPageLimit = 2 + + adapter = NavigationFragmentAdapter(this).apply { + addFragment(DashboardGalleryFragment()) + addFragment(router.getProgramFragmentInstance()) + } + binding.viewPager.adapter = adapter + binding.viewPager.setUserInputEnabled(false) + } +} + +@Composable +private fun Header( + fragmentManager: FragmentManager, + viewPager: ViewPager2 +) { + val viewModel: LearnViewModel = koinViewModel() + val windowSize = rememberWindowSize() + val contentWidth by remember(key1 = windowSize) { + mutableStateOf( + windowSize.windowSizeValue( + expanded = Modifier.widthIn(Dp.Unspecified, 650.dp), + compact = Modifier.fillMaxWidth(), + ) + ) + } + + Column( + modifier = Modifier + .background(MaterialTheme.appColors.background) + .statusBarsInset() + .displayCutoutForLandscape() + .then(contentWidth), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Title( + label = stringResource(id = R.string.dashboard_learn), + onSettingsClick = { + viewModel.onSettingsClick(fragmentManager) + } + ) + + if (viewModel.isProgramTypeWebView) { + LearnDropdownMenu( + modifier = Modifier + .align(Alignment.Start) + .padding(horizontal = 16.dp), + viewPager = viewPager + ) + } + } +} + +@Composable +private fun Title( + modifier: Modifier = Modifier, + label: String, + onSettingsClick: () -> Unit +) { + Box( + modifier = modifier.fillMaxWidth() + ) { + Text( + modifier = Modifier + .align(Alignment.CenterStart) + .padding(start = 16.dp), + text = label, + color = MaterialTheme.appColors.textDark, + style = MaterialTheme.appTypography.headlineBold + ) + IconButton( + modifier = Modifier + .align(Alignment.CenterEnd) + .padding(end = 12.dp), + onClick = { + onSettingsClick() + } + ) { + Icon( + imageVector = Icons.Default.ManageAccounts, + tint = MaterialTheme.appColors.textAccent, + contentDescription = stringResource(id = CoreR.string.core_accessibility_settings) + ) + } + } +} + +@Composable +private fun LearnDropdownMenu( + modifier: Modifier = Modifier, + viewPager: ViewPager2 +) { + var expanded by remember { mutableStateOf(false) } + var currentValue by remember { mutableStateOf(LearnType.COURSES) } + val iconRotation by animateFloatAsState( + targetValue = if (expanded) 180f else 0f, + label = "" + ) + + LaunchedEffect(currentValue) { + viewPager.setCurrentItem( + when (currentValue) { + LearnType.COURSES -> 0 + LearnType.PROGRAMS -> 1 + }, false + ) + } + + Column( + modifier = modifier + ) { + Row( + modifier = Modifier + .clickable { + expanded = true + }, + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = stringResource(id = currentValue.title), + color = MaterialTheme.appColors.textDark, + style = MaterialTheme.appTypography.titleSmall + ) + Icon( + modifier = Modifier.rotate(iconRotation), + imageVector = Icons.Default.ExpandMore, + tint = MaterialTheme.appColors.textDark, + contentDescription = null + ) + } + + MaterialTheme( + colors = MaterialTheme.colors.copy(surface = MaterialTheme.appColors.background), + shapes = MaterialTheme.shapes.copy(medium = RoundedCornerShape(bottomStart = 8.dp, bottomEnd = 8.dp)) + ) { + DropdownMenu( + modifier = Modifier + .crop(vertical = 8.dp) + .widthIn(min = 182.dp), + expanded = expanded, + onDismissRequest = { expanded = false } + ) { + for (learnType in LearnType.entries) { + val background: Color + val textColor: Color + if (currentValue == learnType) { + background = MaterialTheme.appColors.primary + textColor = MaterialTheme.appColors.primaryButtonText + } else { + background = Color.Transparent + textColor = MaterialTheme.appColors.textDark + } + DropdownMenuItem( + modifier = Modifier + .background(background), + onClick = { + currentValue = learnType + expanded = false + } + ) { + Text( + text = stringResource(id = learnType.title), + style = MaterialTheme.appTypography.titleSmall, + color = textColor + ) + } + } + } + } + } +} + +@Preview +@Composable +private fun HeaderPreview() { + OpenEdXTheme { + Title( + label = stringResource(id = R.string.dashboard_learn), + onSettingsClick = {} + ) + } +} + +@Preview +@Composable +private fun LearnDropdownMenuPreview() { + OpenEdXTheme { + val context = LocalContext.current + LearnDropdownMenu( + viewPager = ViewPager2(context) + ) + } +} diff --git a/dashboard/src/main/java/org/openedx/learn/presentation/LearnViewModel.kt b/dashboard/src/main/java/org/openedx/learn/presentation/LearnViewModel.kt new file mode 100644 index 000000000..d2300f652 --- /dev/null +++ b/dashboard/src/main/java/org/openedx/learn/presentation/LearnViewModel.kt @@ -0,0 +1,18 @@ +package org.openedx.learn.presentation + +import androidx.fragment.app.FragmentManager +import org.openedx.core.BaseViewModel +import org.openedx.core.config.Config +import org.openedx.dashboard.presentation.DashboardRouter + +class LearnViewModel( + private val config: Config, + private val dashboardRouter: DashboardRouter +) : BaseViewModel() { + + val isProgramTypeWebView get() = config.getProgramConfig().isViewTypeWebView() + + fun onSettingsClick(fragmentManager: FragmentManager) { + dashboardRouter.navigateToSettings(fragmentManager) + } +} diff --git a/dashboard/src/main/res/drawable/dashboard_ic_book.xml b/dashboard/src/main/res/drawable/dashboard_ic_book.xml new file mode 100644 index 000000000..dd802ee92 --- /dev/null +++ b/dashboard/src/main/res/drawable/dashboard_ic_book.xml @@ -0,0 +1,44 @@ + + + + + + + + + + diff --git a/dashboard/src/main/res/layout/fragment_learn.xml b/dashboard/src/main/res/layout/fragment_learn.xml new file mode 100644 index 000000000..c6556b364 --- /dev/null +++ b/dashboard/src/main/res/layout/fragment_learn.xml @@ -0,0 +1,24 @@ + + + + + + + + diff --git a/dashboard/src/main/res/values/strings.xml b/dashboard/src/main/res/values/strings.xml index 583851adc..4ca0c4fce 100644 --- a/dashboard/src/main/res/values/strings.xml +++ b/dashboard/src/main/res/values/strings.xml @@ -1,7 +1,25 @@ - + Dashboard Courses Welcome back. Let\'s keep learning. You are not enrolled in any courses yet. + Learn + Programs + Course %1$s + Start Course + Resume Course + %1$d Past Due Assignments + View All Courses (%1$d) + View All + %1$s Due in %2$s + All + In Progress + Completed + Expired + All Courses + No Courses + You are not currently enrolled in any courses, would you like to explore the course catalog? + Find a Course + No %1$s Courses diff --git a/dashboard/src/test/java/org/openedx/dashboard/presentation/DashboardViewModelTest.kt b/dashboard/src/test/java/org/openedx/dashboard/presentation/DashboardViewModelTest.kt index 6fdfdec22..6ca20a255 100644 --- a/dashboard/src/test/java/org/openedx/dashboard/presentation/DashboardViewModelTest.kt +++ b/dashboard/src/test/java/org/openedx/dashboard/presentation/DashboardViewModelTest.kt @@ -77,7 +77,7 @@ class DashboardViewModelTest { @Test fun `getCourses no internet connection`() = runTest { - val viewModel = DashboardViewModel( + val viewModel = DashboardListViewModel( config, networkConnection, interactor, @@ -101,7 +101,7 @@ class DashboardViewModelTest { @Test fun `getCourses unknown error`() = runTest { - val viewModel = DashboardViewModel( + val viewModel = DashboardListViewModel( config, networkConnection, interactor, @@ -125,7 +125,7 @@ class DashboardViewModelTest { @Test fun `getCourses from network`() = runTest { - val viewModel = DashboardViewModel( + val viewModel = DashboardListViewModel( config, networkConnection, interactor, @@ -149,7 +149,7 @@ class DashboardViewModelTest { @Test fun `getCourses from network with next page`() = runTest { - val viewModel = DashboardViewModel( + val viewModel = DashboardListViewModel( config, networkConnection, interactor, @@ -183,7 +183,7 @@ class DashboardViewModelTest { fun `getCourses from cache`() = runTest { every { networkConnection.isOnline() } returns false coEvery { interactor.getEnrolledCoursesFromCache() } returns listOf(mockk()) - val viewModel = DashboardViewModel( + val viewModel = DashboardListViewModel( config, networkConnection, interactor, @@ -207,7 +207,7 @@ class DashboardViewModelTest { fun `updateCourses no internet error`() = runTest { every { networkConnection.isOnline() } returns true coEvery { interactor.getEnrolledCourses(any()) } returns dashboardCourseList - val viewModel = DashboardViewModel( + val viewModel = DashboardListViewModel( config, networkConnection, interactor, @@ -235,7 +235,7 @@ class DashboardViewModelTest { fun `updateCourses unknown exception`() = runTest { every { networkConnection.isOnline() } returns true coEvery { interactor.getEnrolledCourses(any()) } returns dashboardCourseList - val viewModel = DashboardViewModel( + val viewModel = DashboardListViewModel( config, networkConnection, interactor, @@ -263,7 +263,7 @@ class DashboardViewModelTest { fun `updateCourses success`() = runTest { every { networkConnection.isOnline() } returns true coEvery { interactor.getEnrolledCourses(any()) } returns dashboardCourseList - val viewModel = DashboardViewModel( + val viewModel = DashboardListViewModel( config, networkConnection, interactor, @@ -296,7 +296,7 @@ class DashboardViewModelTest { "" ) ) - val viewModel = DashboardViewModel( + val viewModel = DashboardListViewModel( config, networkConnection, interactor, @@ -321,7 +321,7 @@ class DashboardViewModelTest { @Test fun `CourseDashboardUpdate notifier test`() = runTest { coEvery { discoveryNotifier.notifier } returns flow { emit(CourseDashboardUpdate()) } - val viewModel = DashboardViewModel( + val viewModel = DashboardListViewModel( config, networkConnection, interactor, diff --git a/default_config/dev/config.yaml b/default_config/dev/config.yaml index 95885ba8a..08fab937f 100644 --- a/default_config/dev/config.yaml +++ b/default_config/dev/config.yaml @@ -28,6 +28,9 @@ PROGRAM: PROGRAM_URL: '' PROGRAM_DETAIL_URL_TEMPLATE: '' +DASHBOARD: + TYPE: 'gallery' + FIREBASE: ENABLED: false ANALYTICS_SOURCE: '' # segment | none diff --git a/default_config/prod/config.yaml b/default_config/prod/config.yaml index 95885ba8a..08fab937f 100644 --- a/default_config/prod/config.yaml +++ b/default_config/prod/config.yaml @@ -28,6 +28,9 @@ PROGRAM: PROGRAM_URL: '' PROGRAM_DETAIL_URL_TEMPLATE: '' +DASHBOARD: + TYPE: 'gallery' + FIREBASE: ENABLED: false ANALYTICS_SOURCE: '' # segment | none diff --git a/default_config/stage/config.yaml b/default_config/stage/config.yaml index 95885ba8a..08fab937f 100644 --- a/default_config/stage/config.yaml +++ b/default_config/stage/config.yaml @@ -28,6 +28,9 @@ PROGRAM: PROGRAM_URL: '' PROGRAM_DETAIL_URL_TEMPLATE: '' +DASHBOARD: + TYPE: 'gallery' + FIREBASE: ENABLED: false ANALYTICS_SOURCE: '' # segment | none diff --git a/discovery/src/main/java/org/openedx/discovery/presentation/program/ProgramFragment.kt b/discovery/src/main/java/org/openedx/discovery/presentation/program/ProgramFragment.kt index ee3e04a3b..3b74dbc42 100644 --- a/discovery/src/main/java/org/openedx/discovery/presentation/program/ProgramFragment.kt +++ b/discovery/src/main/java/org/openedx/discovery/presentation/program/ProgramFragment.kt @@ -68,7 +68,10 @@ import org.openedx.discovery.presentation.catalog.WebViewLink import org.openedx.core.R as coreR import org.openedx.discovery.presentation.catalog.WebViewLink.Authority as linkAuthority -class ProgramFragment(private val myPrograms: Boolean = false) : Fragment() { +class ProgramFragment( + private val myPrograms: Boolean = false, + private val isNestedFragment: Boolean = false +) : Fragment() { private val viewModel by viewModel() @@ -127,6 +130,7 @@ class ProgramFragment(private val myPrograms: Boolean = false) : Fragment() { cookieManager = viewModel.cookieManager, canShowBackBtn = arguments?.getString(ARG_PATH_ID, "") ?.isNotEmpty() == true, + isNestedFragment = isNestedFragment, uriScheme = viewModel.uriScheme, hasInternetConnection = hasInternetConnection, checkInternetConnection = { @@ -224,6 +228,7 @@ private fun ProgramInfoScreen( cookieManager: AppCookieManager, uriScheme: String, canShowBackBtn: Boolean, + isNestedFragment: Boolean, hasInternetConnection: Boolean, checkInternetConnection: () -> Unit, onWebPageLoaded: () -> Unit, @@ -250,7 +255,7 @@ private fun ProgramInfoScreen( .fillMaxSize() .semantics { testTagsAsResourceId = true }, backgroundColor = MaterialTheme.appColors.background - ) { + ) { paddingValues -> val modifierScreenWidth by remember(key1 = windowSize) { mutableStateOf( windowSize.windowSizeValue( @@ -264,21 +269,29 @@ private fun ProgramInfoScreen( ) } + val statusBarPadding = if (isNestedFragment) { + Modifier + } else { + Modifier.statusBarsInset() + } + Column( modifier = Modifier .fillMaxSize() - .padding(it) - .statusBarsInset() + .padding(paddingValues) + .then(statusBarPadding) .displayCutoutForLandscape(), horizontalAlignment = Alignment.CenterHorizontally, ) { - Toolbar( - label = stringResource(id = R.string.discovery_programs), - canShowBackBtn = canShowBackBtn, - canShowSettingsIcon = !canShowBackBtn, - onBackClick = onBackClick, - onSettingsClick = onSettingsClick - ) + if (!isNestedFragment) { + Toolbar( + label = stringResource(id = R.string.discovery_programs), + canShowBackBtn = canShowBackBtn, + canShowSettingsIcon = !canShowBackBtn, + onBackClick = onBackClick, + onSettingsClick = onSettingsClick + ) + } Surface { Box( @@ -349,6 +362,7 @@ fun MyProgramsPreview() { cookieManager = koinViewModel().cookieManager, uriScheme = "", canShowBackBtn = false, + isNestedFragment = false, hasInternetConnection = false, checkInternetConnection = {}, onBackClick = {}, diff --git a/discussion/src/main/java/org/openedx/discussion/presentation/topics/DiscussionTopicsViewModel.kt b/discussion/src/main/java/org/openedx/discussion/presentation/topics/DiscussionTopicsViewModel.kt index 46552edc9..456eb79c2 100644 --- a/discussion/src/main/java/org/openedx/discussion/presentation/topics/DiscussionTopicsViewModel.kt +++ b/discussion/src/main/java/org/openedx/discussion/presentation/topics/DiscussionTopicsViewModel.kt @@ -11,11 +11,10 @@ import org.openedx.core.BaseViewModel import org.openedx.core.R import org.openedx.core.UIMessage import org.openedx.core.extension.isInternetError -import org.openedx.core.presentation.course.CourseContainerTab import org.openedx.core.system.ResourceManager import org.openedx.core.system.notifier.CourseLoading import org.openedx.core.system.notifier.CourseNotifier -import org.openedx.core.system.notifier.CourseRefresh +import org.openedx.core.system.notifier.RefreshDiscussions import org.openedx.discussion.domain.interactor.DiscussionInteractor import org.openedx.discussion.presentation.DiscussionAnalytics import org.openedx.discussion.presentation.DiscussionRouter @@ -81,11 +80,7 @@ class DiscussionTopicsViewModel( viewModelScope.launch { courseNotifier.notifier.collect { event -> when (event) { - is CourseRefresh -> { - if (event.courseContainerTab == CourseContainerTab.DISCUSSIONS) { - getCourseTopic() - } - } + is RefreshDiscussions -> getCourseTopic() } } } diff --git a/profile/src/main/java/org/openedx/profile/presentation/calendar/CalendarAccessDialogFragment.kt b/profile/src/main/java/org/openedx/profile/presentation/calendar/CalendarAccessDialogFragment.kt index a9094c67d..8d49fb8ec 100644 --- a/profile/src/main/java/org/openedx/profile/presentation/calendar/CalendarAccessDialogFragment.kt +++ b/profile/src/main/java/org/openedx/profile/presentation/calendar/CalendarAccessDialogFragment.kt @@ -128,7 +128,7 @@ private fun CalendarAccessDialog( TextIcon( text = stringResource(id = R.string.profile_grant_access_calendar), icon = Icons.AutoMirrored.Filled.OpenInNew, - color = MaterialTheme.appColors.buttonText, + color = MaterialTheme.appColors.primaryButtonText, textStyle = MaterialTheme.appTypography.labelLarge, iconModifier = Modifier.padding(start = 4.dp) ) @@ -138,8 +138,8 @@ private fun CalendarAccessDialog( modifier = Modifier.fillMaxWidth(), text = stringResource(id = CoreR.string.core_cancel), backgroundColor = MaterialTheme.appColors.background, - borderColor = MaterialTheme.appColors.buttonBackground, - textColor = MaterialTheme.appColors.buttonBackground, + borderColor = MaterialTheme.appColors.primaryButtonBackground, + textColor = MaterialTheme.appColors.primaryButtonBackground, onClick = { onCancelClick() } diff --git a/profile/src/main/java/org/openedx/profile/presentation/calendar/NewCalendarDialogFragment.kt b/profile/src/main/java/org/openedx/profile/presentation/calendar/NewCalendarDialogFragment.kt index bfd453f5c..8e55b885b 100644 --- a/profile/src/main/java/org/openedx/profile/presentation/calendar/NewCalendarDialogFragment.kt +++ b/profile/src/main/java/org/openedx/profile/presentation/calendar/NewCalendarDialogFragment.kt @@ -172,8 +172,8 @@ private fun NewCalendarDialog( modifier = Modifier.fillMaxWidth(), text = stringResource(id = CoreR.string.core_cancel), backgroundColor = MaterialTheme.appColors.background, - borderColor = MaterialTheme.appColors.buttonBackground, - textColor = MaterialTheme.appColors.buttonBackground, + borderColor = MaterialTheme.appColors.primaryButtonBackground, + textColor = MaterialTheme.appColors.primaryButtonBackground, onClick = { onCancelClick() } From d0feb1878c7e3cbbfc0d9ac3ed78ff13f78a19f1 Mon Sep 17 00:00:00 2001 From: PavloNetrebchuk Date: Thu, 30 May 2024 15:11:51 +0300 Subject: [PATCH 13/18] fix: Assignment date string --- .../courses/presentation/DashboardGalleryView.kt | 13 +++++++++---- dashboard/src/main/res/values/strings.xml | 12 ++++++++++-- 2 files changed, 19 insertions(+), 6 deletions(-) diff --git a/dashboard/src/main/java/org/openedx/courses/presentation/DashboardGalleryView.kt b/dashboard/src/main/java/org/openedx/courses/presentation/DashboardGalleryView.kt index c4ea029b9..74515e0c1 100644 --- a/dashboard/src/main/java/org/openedx/courses/presentation/DashboardGalleryView.kt +++ b/dashboard/src/main/java/org/openedx/courses/presentation/DashboardGalleryView.kt @@ -55,6 +55,7 @@ import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.pluralStringResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow @@ -564,7 +565,11 @@ private fun PrimaryCourseCard( }, painter = rememberVectorPainter(Icons.Default.Warning), title = title, - info = stringResource(R.string.dashboard_past_due_assignment, pastAssignments.size) + info = pluralStringResource( + R.plurals.dashboard_past_due_assignment, + pastAssignments.size, + pastAssignments.size + ) ) } val futureAssignments = primaryCourse.courseAssignments?.futureAssignments @@ -583,9 +588,9 @@ private fun PrimaryCourseCard( painter = painterResource(id = CoreR.drawable.ic_core_chapter_icon), title = title, info = stringResource( - R.string.dashboard_assignment_due_in_days, + R.string.dashboard_assignment_due, nearestAssignment.assignmentType ?: "", - TimeUtils.getCourseFormattedDate(context, nearestAssignment.date) + TimeUtils.getAssignmentFormattedDate(context, nearestAssignment.date) ) ) } @@ -769,7 +774,7 @@ private fun NoCoursesInfo( private val mockCourseDateBlock = CourseDateBlock( title = "Homework 1: ABCD", description = "After this date, course content will be archived", - date = TimeUtils.iso8601ToDate("2023-10-20T15:08:07Z")!!, + date = TimeUtils.iso8601ToDate("2024-05-31T15:08:07Z")!!, assignmentType = "Homework" ) private val mockCourseAssignments = diff --git a/dashboard/src/main/res/values/strings.xml b/dashboard/src/main/res/values/strings.xml index 4ca0c4fce..23a33fb50 100644 --- a/dashboard/src/main/res/values/strings.xml +++ b/dashboard/src/main/res/values/strings.xml @@ -9,10 +9,9 @@ Course %1$s Start Course Resume Course - %1$d Past Due Assignments View All Courses (%1$d) View All - %1$s Due in %2$s + %1$s %2$s All In Progress Completed @@ -22,4 +21,13 @@ You are not currently enrolled in any courses, would you like to explore the course catalog? Find a Course No %1$s Courses + + + %1$d Past Due Assignments + %1$d Past Due Assignment + %1$d Past Due Assignments + %1$d Past Due Assignments + %1$d Past Due Assignments + %1$d Past Due Assignments + From b72fc5939deaefb315b2261c38590b3f4ef24d15 Mon Sep 17 00:00:00 2001 From: PavloNetrebchuk Date: Thu, 30 May 2024 11:11:05 +0300 Subject: [PATCH 14/18] fix: Lint error --- .../model/room/discovery/EnrolledCourseEntity.kt | 3 +++ core/src/openedx/org/openedx/core/ui/theme/Colors.kt | 2 -- .../presentation/outline/CourseOutlineScreen.kt | 7 ++----- .../org/openedx/course/presentation/ui/CourseUI.kt | 8 +++----- .../openedx/course/presentation/ui/CourseVideosUI.kt | 12 ++++++------ 5 files changed, 14 insertions(+), 18 deletions(-) diff --git a/core/src/main/java/org/openedx/core/data/model/room/discovery/EnrolledCourseEntity.kt b/core/src/main/java/org/openedx/core/data/model/room/discovery/EnrolledCourseEntity.kt index 00c0c37e9..e019f6300 100644 --- a/core/src/main/java/org/openedx/core/data/model/room/discovery/EnrolledCourseEntity.kt +++ b/core/src/main/java/org/openedx/core/data/model/room/discovery/EnrolledCourseEntity.kt @@ -7,7 +7,10 @@ import androidx.room.PrimaryKey import org.openedx.core.data.model.DateType import org.openedx.core.data.model.room.MediaDb import org.openedx.core.domain.model.Certificate +import org.openedx.core.domain.model.CourseAssignments +import org.openedx.core.domain.model.CourseDateBlock import org.openedx.core.domain.model.CourseSharingUtmParameters +import org.openedx.core.domain.model.CourseStatus import org.openedx.core.domain.model.CoursewareAccess import org.openedx.core.domain.model.EnrolledCourse import org.openedx.core.domain.model.EnrolledCourseData 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 83f655df6..855d557d3 100644 --- a/core/src/openedx/org/openedx/core/ui/theme/Colors.kt +++ b/core/src/openedx/org/openedx/core/ui/theme/Colors.kt @@ -51,7 +51,6 @@ val light_warning = Color(0xFFFFC94D) val light_info = Color(0xFF42AAFF) val light_rate_stars = Color(0xFFFFC94D) val light_inactive_button_background = Color(0xFFCCD4E0) -val light_inactive_button_text = Color(0xFF3D4964) val light_success_green = Color(0xFF198571) val light_dates_section_bar_past_due = light_warning val light_dates_section_bar_today = light_info @@ -124,7 +123,6 @@ val dark_info_variant = Color(0xFF5478F9) val dark_onInfo = Color.White val dark_rate_stars = Color(0xFFFFC94D) val dark_inactive_button_background = Color(0xFFCCD4E0) -val dark_inactive_button_text = Color(0xFF3D4964) val dark_success_green = Color(0xFF198571) val dark_dates_section_bar_past_due = dark_warning val dark_dates_section_bar_today = dark_info diff --git a/course/src/main/java/org/openedx/course/presentation/outline/CourseOutlineScreen.kt b/course/src/main/java/org/openedx/course/presentation/outline/CourseOutlineScreen.kt index 464fa163b..dfe53ce0d 100644 --- a/course/src/main/java/org/openedx/course/presentation/outline/CourseOutlineScreen.kt +++ b/course/src/main/java/org/openedx/course/presentation/outline/CourseOutlineScreen.kt @@ -68,11 +68,8 @@ import org.openedx.core.utils.FileUtil import org.openedx.course.R import org.openedx.course.presentation.ui.CourseDatesBanner import org.openedx.course.presentation.ui.CourseDatesBannerTablet -import org.openedx.course.presentation.ui.CourseSection -import org.openedx.course.presentation.ui.CourseExpandableChapterCard import org.openedx.course.presentation.ui.CourseMessage -import org.openedx.course.presentation.ui.CourseSectionCard -import org.openedx.course.presentation.ui.CourseSubSectionItem +import org.openedx.course.presentation.ui.CourseSection import java.util.Date import org.openedx.core.R as CoreR @@ -151,7 +148,7 @@ fun CourseOutlineScreen( viewModel.removeDownloadModels(blockId) } else { viewModel.saveDownloadModels( - FileUtil.getExternalAppDir(context).path, blockId + FileUtil(context).getExternalAppDir().path, blockId ) } } 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 fc3be04d4..60612f2bf 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 @@ -47,8 +47,6 @@ import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.ChevronRight import androidx.compose.material.icons.filled.Close import androidx.compose.material.icons.filled.CloudDone -import androidx.compose.material.icons.filled.Home -import androidx.compose.material.icons.filled.TaskAlt import androidx.compose.material.rememberScaffoldState import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect @@ -62,9 +60,10 @@ import androidx.compose.ui.draw.alpha import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.rotate import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.vector.rememberVectorPainter import androidx.compose.ui.graphics.painter.Painter +import androidx.compose.ui.graphics.vector.rememberVectorPainter import androidx.compose.ui.platform.LocalConfiguration +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.semantics.semantics @@ -82,7 +81,6 @@ import org.openedx.core.domain.model.AssignmentProgress import org.openedx.core.domain.model.Block import org.openedx.core.domain.model.BlockCounts import org.openedx.core.domain.model.CourseDatesBannerInfo -import org.openedx.core.extension.isLinkValid import org.openedx.core.extension.nonZero import org.openedx.core.extension.toFileSize import org.openedx.core.module.db.DownloadModel @@ -747,7 +745,7 @@ fun CourseSubSectionItem( onClick: (Block) -> Unit ) { val icon = - if (block.isCompleted()) painterResource(R.drawable.course_ic_task_alt) else painterResource(R.drawable.ic_course_chapter_icon) + if (block.isCompleted()) painterResource(R.drawable.course_ic_task_alt) else painterResource(coreR.drawable.ic_core_chapter_icon) val iconColor = if (block.isCompleted()) MaterialTheme.appColors.successGreen else MaterialTheme.appColors.onSurface diff --git a/course/src/main/java/org/openedx/course/presentation/ui/CourseVideosUI.kt b/course/src/main/java/org/openedx/course/presentation/ui/CourseVideosUI.kt index 12a11cd14..c7926c619 100644 --- a/course/src/main/java/org/openedx/course/presentation/ui/CourseVideosUI.kt +++ b/course/src/main/java/org/openedx/course/presentation/ui/CourseVideosUI.kt @@ -126,16 +126,16 @@ fun CourseVideosScreen( }, onDownloadClick = { blocksIds -> blocksIds.forEach { blockId -> - if (courseVideoViewModel.isBlockDownloading(blockId)) { - courseRouter.navigateToDownloadQueue( + if (viewModel.isBlockDownloading(blockId)) { + viewModel.courseRouter.navigateToDownloadQueue( fm = fragmentManager, - courseVideoViewModel.getDownloadableChildren(blockId) + viewModel.getDownloadableChildren(blockId) ?: arrayListOf() ) - } else if (courseVideoViewModel.isBlockDownloaded(blockId)) { - courseVideoViewModel.removeDownloadModels(blockId) + } else if (viewModel.isBlockDownloaded(blockId)) { + viewModel.removeDownloadModels(blockId) } else { - courseVideoViewModel.saveDownloadModels( + viewModel.saveDownloadModels( FileUtil(context).getExternalAppDir().path, blockId ) } From 84dfdba50198beadc8070edb08306c34a5548d22 Mon Sep 17 00:00:00 2001 From: PavloNetrebchuk Date: Fri, 31 May 2024 11:25:17 +0300 Subject: [PATCH 15/18] fix: Assignment date string --- .../src/main/java/org/openedx/core/utils/TimeUtils.kt | 11 +++++++++-- core/src/main/res/values/strings.xml | 2 ++ 2 files changed, 11 insertions(+), 2 deletions(-) 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 908e650a1..18f40563a 100644 --- a/core/src/main/java/org/openedx/core/utils/TimeUtils.kt +++ b/core/src/main/java/org/openedx/core/utils/TimeUtils.kt @@ -258,8 +258,15 @@ object TimeUtils { context.getString(R.string.core_date_format_assignment_due_tomorrow) } - daysDifference <= -1 -> { - context.getString(R.string.core_date_format_past_due_assignment) + daysDifference == -1 -> { + context.getString(R.string.core_date_format_assignment_due_yesterday) + } + + daysDifference <= -2 -> { + context.getString( + R.string.core_date_format_assignment_due_days_ago, + ceil(-daysDifference.toDouble()).toInt().toString() + ) } else -> { diff --git a/core/src/main/res/values/strings.xml b/core/src/main/res/values/strings.xml index d9f5972ac..1e3854862 100644 --- a/core/src/main/res/values/strings.xml +++ b/core/src/main/res/values/strings.xml @@ -99,7 +99,9 @@ Past due assignment Due Today Due Tomorrow + Due Yesterday Due in %1$s days + Due %1$s days ago %d Item Hidden %d Items Hidden From 8eb71c7fa230507c87b6a89a84ad381e3f876db6 Mon Sep 17 00:00:00 2001 From: PavloNetrebchuk Date: Fri, 31 May 2024 13:41:31 +0300 Subject: [PATCH 16/18] fix: Fixes according to PR feedback --- Documentation/ConfigurationManagement.md | 2 +- .../core/data/model/AssignmentProgress.kt | 3 +- .../java/org/openedx/core/data/model/Block.kt | 26 ++++++----- .../openedx/core/data/model/room/BlockDb.kt | 46 +++++++++++-------- .../org/openedx/core/domain/model/Progress.kt | 4 +- .../java/org/openedx/core/utils/TimeUtils.kt | 16 ++++--- core/src/main/res/values/strings.xml | 19 ++++++-- .../outline/CourseOutlineScreen.kt | 8 ++-- .../course/presentation/ui/CourseUI.kt | 10 ++-- .../course/presentation/ui/CourseVideosUI.kt | 3 +- course/src/main/res/values/strings.xml | 10 +++- 11 files changed, 95 insertions(+), 52 deletions(-) diff --git a/Documentation/ConfigurationManagement.md b/Documentation/ConfigurationManagement.md index 1f12f3414..c3786b1d6 100644 --- a/Documentation/ConfigurationManagement.md +++ b/Documentation/ConfigurationManagement.md @@ -88,7 +88,7 @@ android: - **PRE_LOGIN_EXPERIENCE_ENABLED:** Enables the pre login courses discovery experience. - **WHATS_NEW_ENABLED:** Enables the "What's New" feature to present the latest changes to the user. - **SOCIAL_AUTH_ENABLED:** Enables SSO buttons on the SignIn and SignUp screens. -- **COURSE_DROPDOWN_NAVIGATION_ENABLED:** Enables an alternative visual representation for the course structure. +- **COURSE_DROPDOWN_NAVIGATION_ENABLED:** Enables an alternative navigation through units. - **COURSE_UNIT_PROGRESS_ENABLED:** Enables the display of the unit progress within the courseware. ## Future Support diff --git a/core/src/main/java/org/openedx/core/data/model/AssignmentProgress.kt b/core/src/main/java/org/openedx/core/data/model/AssignmentProgress.kt index 040087349..2ac10cb18 100644 --- a/core/src/main/java/org/openedx/core/data/model/AssignmentProgress.kt +++ b/core/src/main/java/org/openedx/core/data/model/AssignmentProgress.kt @@ -2,6 +2,7 @@ package org.openedx.core.data.model import com.google.gson.annotations.SerializedName import org.openedx.core.data.model.room.AssignmentProgressDb +import org.openedx.core.domain.model.AssignmentProgress data class AssignmentProgress( @SerializedName("assignment_type") @@ -11,7 +12,7 @@ data class AssignmentProgress( @SerializedName("num_points_possible") val numPointsPossible: Float?, ) { - fun mapToDomain() = org.openedx.core.domain.model.AssignmentProgress( + fun mapToDomain() = AssignmentProgress( assignmentType = assignmentType ?: "", numPointsEarned = numPointsEarned ?: 0f, numPointsPossible = numPointsPossible ?: 0f diff --git a/core/src/main/java/org/openedx/core/data/model/Block.kt b/core/src/main/java/org/openedx/core/data/model/Block.kt index 07ff5d49a..b5581209f 100644 --- a/core/src/main/java/org/openedx/core/data/model/Block.kt +++ b/core/src/main/java/org/openedx/core/data/model/Block.kt @@ -2,8 +2,12 @@ package org.openedx.core.data.model import com.google.gson.annotations.SerializedName import org.openedx.core.BlockType -import org.openedx.core.domain.model.Block import org.openedx.core.utils.TimeUtils +import org.openedx.core.domain.model.Block as DomainBlock +import org.openedx.core.domain.model.BlockCounts as DomainBlockCounts +import org.openedx.core.domain.model.EncodedVideos as DomainEncodedVideos +import org.openedx.core.domain.model.StudentViewData as DomainStudentViewData +import org.openedx.core.domain.model.VideoInfo as DomainVideoInfo data class Block( @SerializedName("id") @@ -39,7 +43,7 @@ data class Block( @SerializedName("due") val due: String? ) { - fun mapToDomain(blockData: Map): Block { + fun mapToDomain(blockData: Map): DomainBlock { val blockType = BlockType.getBlockType(type ?: "") val descendantsType = if (blockType == BlockType.VERTICAL) { val types = descendants?.map { descendant -> @@ -51,7 +55,7 @@ data class Block( blockType } - return org.openedx.core.domain.model.Block( + return DomainBlock( id = id ?: "", blockId = blockId ?: "", lmsWebUrl = lmsWebUrl ?: "", @@ -87,8 +91,8 @@ data class StudentViewData( @SerializedName("topic_id") val topicId: String? ) { - fun mapToDomain(): org.openedx.core.domain.model.StudentViewData { - return org.openedx.core.domain.model.StudentViewData( + fun mapToDomain(): DomainStudentViewData { + return DomainStudentViewData( onlyOnWeb = onlyOnWeb ?: false, duration = duration ?: "", transcripts = transcripts, @@ -113,8 +117,8 @@ data class EncodedVideos( var mobileLow: VideoInfo? ) { - fun mapToDomain(): org.openedx.core.domain.model.EncodedVideos { - return org.openedx.core.domain.model.EncodedVideos( + fun mapToDomain(): DomainEncodedVideos { + return DomainEncodedVideos( youtube = videoInfo?.mapToDomain(), hls = hls?.mapToDomain(), fallback = fallback?.mapToDomain(), @@ -131,8 +135,8 @@ data class VideoInfo( @SerializedName("file_size") var fileSize: Int? ) { - fun mapToDomain(): org.openedx.core.domain.model.VideoInfo { - return org.openedx.core.domain.model.VideoInfo( + fun mapToDomain(): DomainVideoInfo { + return DomainVideoInfo( url = url ?: "", fileSize = fileSize ?: 0 ) @@ -143,8 +147,8 @@ data class BlockCounts( @SerializedName("video") var video: Int? ) { - fun mapToDomain(): org.openedx.core.domain.model.BlockCounts { - return org.openedx.core.domain.model.BlockCounts( + fun mapToDomain(): DomainBlockCounts { + return DomainBlockCounts( video = video ?: 0 ) } diff --git a/core/src/main/java/org/openedx/core/data/model/room/BlockDb.kt b/core/src/main/java/org/openedx/core/data/model/room/BlockDb.kt index 5f362862f..737437dd0 100644 --- a/core/src/main/java/org/openedx/core/data/model/room/BlockDb.kt +++ b/core/src/main/java/org/openedx/core/data/model/room/BlockDb.kt @@ -3,12 +3,18 @@ package org.openedx.core.data.model.room import androidx.room.ColumnInfo import androidx.room.Embedded import org.openedx.core.BlockType -import org.openedx.core.domain.model.Block -import org.openedx.core.domain.model.BlockCounts -import org.openedx.core.domain.model.EncodedVideos -import org.openedx.core.domain.model.StudentViewData -import org.openedx.core.domain.model.VideoInfo +import org.openedx.core.data.model.Block +import org.openedx.core.data.model.BlockCounts +import org.openedx.core.data.model.EncodedVideos +import org.openedx.core.data.model.StudentViewData +import org.openedx.core.data.model.VideoInfo import org.openedx.core.utils.TimeUtils +import org.openedx.core.domain.model.AssignmentProgress as DomainAssignmentProgress +import org.openedx.core.domain.model.Block as DomainBlock +import org.openedx.core.domain.model.BlockCounts as DomainBlockCounts +import org.openedx.core.domain.model.EncodedVideos as DomainEncodedVideos +import org.openedx.core.domain.model.StudentViewData as DomainStudentViewData +import org.openedx.core.domain.model.VideoInfo as DomainVideoInfo data class BlockDb( @ColumnInfo("id") @@ -44,7 +50,7 @@ data class BlockDb( @ColumnInfo("due") val due: String? ) { - fun mapToDomain(blocks: List): Block { + fun mapToDomain(blocks: List): DomainBlock { val blockType = BlockType.getBlockType(type) val descendantsType = if (blockType == BlockType.VERTICAL) { val types = descendants.map { descendant -> @@ -56,7 +62,7 @@ data class BlockDb( blockType } - return Block( + return DomainBlock( id = id, blockId = blockId, lmsWebUrl = lmsWebUrl, @@ -80,7 +86,7 @@ data class BlockDb( companion object { fun createFrom( - block: org.openedx.core.data.model.Block + block: Block ): BlockDb { with(block) { return BlockDb( @@ -118,8 +124,8 @@ data class StudentViewDataDb( @Embedded val encodedVideos: EncodedVideosDb? ) { - fun mapToDomain(): StudentViewData { - return StudentViewData( + fun mapToDomain(): DomainStudentViewData { + return DomainStudentViewData( onlyOnWeb, duration, transcripts, @@ -130,7 +136,7 @@ data class StudentViewDataDb( companion object { - fun createFrom(studentViewData: org.openedx.core.data.model.StudentViewData?): StudentViewDataDb { + fun createFrom(studentViewData: StudentViewData?): StudentViewDataDb { return StudentViewDataDb( onlyOnWeb = studentViewData?.onlyOnWeb ?: false, duration = studentViewData?.duration.toString(), @@ -157,9 +163,9 @@ data class EncodedVideosDb( @ColumnInfo("mobileLow") var mobileLow: VideoInfoDb? ) { - fun mapToDomain(): EncodedVideos { - return EncodedVideos( - youtube?.mapToDomain(), + fun mapToDomain(): DomainEncodedVideos { + return DomainEncodedVideos( + youtube = youtube?.mapToDomain(), hls = hls?.mapToDomain(), fallback = fallback?.mapToDomain(), desktopMp4 = desktopMp4?.mapToDomain(), @@ -169,7 +175,7 @@ data class EncodedVideosDb( } companion object { - fun createFrom(encodedVideos: org.openedx.core.data.model.EncodedVideos?): EncodedVideosDb { + fun createFrom(encodedVideos: EncodedVideos?): EncodedVideosDb { return EncodedVideosDb( youtube = VideoInfoDb.createFrom(encodedVideos?.videoInfo), hls = VideoInfoDb.createFrom(encodedVideos?.hls), @@ -189,10 +195,10 @@ data class VideoInfoDb( @ColumnInfo("fileSize") val fileSize: Int ) { - fun mapToDomain() = VideoInfo(url, fileSize) + fun mapToDomain() = DomainVideoInfo(url, fileSize) companion object { - fun createFrom(videoInfo: org.openedx.core.data.model.VideoInfo?): VideoInfoDb? { + fun createFrom(videoInfo: VideoInfo?): VideoInfoDb? { if (videoInfo == null) return null return VideoInfoDb( videoInfo.url ?: "", @@ -206,10 +212,10 @@ data class BlockCountsDb( @ColumnInfo("video") val video: Int ) { - fun mapToDomain() = BlockCounts(video) + fun mapToDomain() = DomainBlockCounts(video) companion object { - fun createFrom(blocksCounts: org.openedx.core.data.model.BlockCounts?): BlockCountsDb { + fun createFrom(blocksCounts: BlockCounts?): BlockCountsDb { return BlockCountsDb(blocksCounts?.video ?: 0) } } @@ -223,7 +229,7 @@ data class AssignmentProgressDb( @ColumnInfo("num_points_possible") val numPointsPossible: Float?, ) { - fun mapToDomain() = org.openedx.core.domain.model.AssignmentProgress( + fun mapToDomain() = DomainAssignmentProgress( assignmentType = assignmentType ?: "", numPointsEarned = numPointsEarned ?: 0f, numPointsPossible = numPointsPossible ?: 0f diff --git a/core/src/main/java/org/openedx/core/domain/model/Progress.kt b/core/src/main/java/org/openedx/core/domain/model/Progress.kt index 12363051a..800a9c292 100644 --- a/core/src/main/java/org/openedx/core/domain/model/Progress.kt +++ b/core/src/main/java/org/openedx/core/domain/model/Progress.kt @@ -1,6 +1,7 @@ package org.openedx.core.domain.model import android.os.Parcelable +import kotlinx.parcelize.IgnoredOnParcel import kotlinx.parcelize.Parcelize @Parcelize @@ -9,7 +10,8 @@ data class Progress( val totalAssignmentsCount: Int, ) : Parcelable { - fun getProgress(): Float = try { + @IgnoredOnParcel + val value: Float = try { assignmentsCompleted.toFloat() / totalAssignmentsCount.toFloat() } catch (_: ArithmeticException) { 0f 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 18f40563a..5327b8cf5 100644 --- a/core/src/main/java/org/openedx/core/utils/TimeUtils.kt +++ b/core/src/main/java/org/openedx/core/utils/TimeUtils.kt @@ -263,16 +263,20 @@ object TimeUtils { } daysDifference <= -2 -> { - context.getString( - R.string.core_date_format_assignment_due_days_ago, - ceil(-daysDifference.toDouble()).toInt().toString() + val numberOfDays = ceil(-daysDifference.toDouble()).toInt() + context.resources.getQuantityString( + R.plurals.core_date_format_assignment_due_days_ago, + numberOfDays, + numberOfDays ) } else -> { - context.getString( - R.string.core_date_format_assignment_due_in, - ceil(daysDifference.toDouble()).toInt().toString() + val numberOfDays = ceil(daysDifference.toDouble()).toInt() + context.resources.getQuantityString( + R.plurals.core_date_format_assignment_due_in, + numberOfDays, + numberOfDays ) } } diff --git a/core/src/main/res/values/strings.xml b/core/src/main/res/values/strings.xml index 1e3854862..580d262ac 100644 --- a/core/src/main/res/values/strings.xml +++ b/core/src/main/res/values/strings.xml @@ -96,12 +96,25 @@ Tomorrow Yesterday %1$s days ago - Past due assignment Due Today Due Tomorrow Due Yesterday - Due in %1$s days - Due %1$s days ago + + Due %1$d days ago + Due %1$d day ago + Due %1$d days ago + Due %1$d days ago + Due %1$d days ago + Due %1$d days ago + + + Due in %1$d days + Due in %1$d day + Due in %1$d days + Due in %1$d days + Due in %1$d days + Due in %1$d days + %d Item Hidden %d Items Hidden diff --git a/course/src/main/java/org/openedx/course/presentation/outline/CourseOutlineScreen.kt b/course/src/main/java/org/openedx/course/presentation/outline/CourseOutlineScreen.kt index dfe53ce0d..1a29819f2 100644 --- a/course/src/main/java/org/openedx/course/presentation/outline/CourseOutlineScreen.kt +++ b/course/src/main/java/org/openedx/course/presentation/outline/CourseOutlineScreen.kt @@ -36,6 +36,7 @@ import androidx.compose.ui.draw.clip import androidx.compose.ui.platform.AndroidUriHandler 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.style.TextOverflow import androidx.compose.ui.tooling.preview.Devices @@ -468,13 +469,14 @@ private fun CourseProgress( .fillMaxWidth() .height(10.dp) .clip(CircleShape), - progress = progress.getProgress(), + progress = progress.value, color = MaterialTheme.appColors.progressBarColor, backgroundColor = MaterialTheme.appColors.progressBarBackgroundColor ) Text( - text = stringResource( - R.string.course_assignments_complete, + text = pluralStringResource( + R.plurals.course_assignments_complete, + progress.assignmentsCompleted, progress.assignmentsCompleted, progress.totalAssignmentsCount ), 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 60612f2bf..9b2787400 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 @@ -52,7 +52,9 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableLongStateOf +import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -609,7 +611,7 @@ fun CourseSection( Column(modifier = modifier .clip(MaterialTheme.appShapes.cardShape) - .clickable { onItemClick(block) } + .noRippleClickable { onItemClick(block) } .background(MaterialTheme.appColors.cardViewBackground) .border( 1.dp, @@ -744,12 +746,14 @@ fun CourseSubSectionItem( block: Block, onClick: (Block) -> Unit ) { + val context = LocalContext.current val icon = if (block.isCompleted()) painterResource(R.drawable.course_ic_task_alt) else painterResource(coreR.drawable.ic_core_chapter_icon) val iconColor = if (block.isCompleted()) MaterialTheme.appColors.successGreen else MaterialTheme.appColors.onSurface - - val due = block.due?.let { TimeUtils.getAssignmentFormattedDate(LocalContext.current, it) } + val due by rememberSaveable { + mutableStateOf(block.due?.let { TimeUtils.getAssignmentFormattedDate(context, it) }) + } val isAssignmentEnable = !block.isCompleted() && block.assignmentProgress != null && !due.isNullOrEmpty() Column( modifier = modifier diff --git a/course/src/main/java/org/openedx/course/presentation/ui/CourseVideosUI.kt b/course/src/main/java/org/openedx/course/presentation/ui/CourseVideosUI.kt index c7926c619..7364ac9cf 100644 --- a/course/src/main/java/org/openedx/course/presentation/ui/CourseVideosUI.kt +++ b/course/src/main/java/org/openedx/course/presentation/ui/CourseVideosUI.kt @@ -129,8 +129,7 @@ fun CourseVideosScreen( if (viewModel.isBlockDownloading(blockId)) { viewModel.courseRouter.navigateToDownloadQueue( fm = fragmentManager, - viewModel.getDownloadableChildren(blockId) - ?: arrayListOf() + viewModel.getDownloadableChildren(blockId) ?: arrayListOf() ) } else if (viewModel.isBlockDownloaded(blockId)) { viewModel.removeDownloadModels(blockId) diff --git a/course/src/main/res/values/strings.xml b/course/src/main/res/values/strings.xml index adbd7bbb6..2fcb1d950 100644 --- a/course/src/main/res/values/strings.xml +++ b/course/src/main/res/values/strings.xml @@ -62,7 +62,15 @@ Turning off the switch will stop downloading and delete all downloaded videos for \"%s\"? Are you sure you want to delete all video(s) for \"%s\"? Are you sure you want to delete video(s) for \"%s\"? - %1$s of %2$s assignments complete %1$s - %2$s - %3$d / %4$d + + %1$s of %2$s assignments complete + %1$s of %2$s assignment complete + %1$s of %2$s assignments complete + %1$s of %2$s assignments complete + %1$s of %2$s assignments complete + %1$s of %2$s assignments complete + + From 6df86fc7a6a127bfb003c8e46ab6665344bc83d9 Mon Sep 17 00:00:00 2001 From: PavloNetrebchuk Date: Tue, 4 Jun 2024 12:16:26 +0300 Subject: [PATCH 17/18] fix: Fixes according to designer feedback --- .../main/java/org/openedx/course/presentation/ui/CourseUI.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 d4cb22f1d..c336b77d5 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 @@ -699,7 +699,7 @@ fun CourseExpandableChapterCard( if (downloadedState == DownloadedState.DOWNLOADED) { MaterialTheme.appColors.successGreen } else { - MaterialTheme.appColors.primary + MaterialTheme.appColors.textAccent } IconButton(modifier = iconModifier, onClick = { onDownloadClick() }) { From 1d3203b194d93170fa45fb0928adce4b1ae9df03 Mon Sep 17 00:00:00 2001 From: PavloNetrebchuk Date: Thu, 6 Jun 2024 14:49:28 +0300 Subject: [PATCH 18/18] fix: Fixes according to PR feedback --- .../course/presentation/ui/CourseUI.kt | 21 +++++++------------ .../course/presentation/ui/CourseVideosUI.kt | 19 +++++------------ .../videos/CourseVideoViewModel.kt | 20 ++++++++++++++++++ 3 files changed, 33 insertions(+), 27 deletions(-) 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 c336b77d5..27da57afb 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 @@ -595,19 +595,14 @@ fun CourseSection( val arrowRotation by animateFloatAsState( targetValue = if (courseSectionsState == true) -90f else 90f, label = "" ) - val subsectionsDownloadedStates = downloadedStateMap.filterKeys { key -> - key in (courseSubSections?.map { it.id } ?: emptyList()) - }.values.toList() - val downloadedState = - if (subsectionsDownloadedStates.isNotEmpty() && subsectionsDownloadedStates.all { it.isDownloaded }) { - DownloadedState.DOWNLOADED - } else if (subsectionsDownloadedStates.firstOrNull { it.isWaitingOrDownloading } != null) { - DownloadedState.DOWNLOADING - } else if (subsectionsDownloadedStates.isNotEmpty()) { - DownloadedState.NOT_DOWNLOADED - } else { - null - } + val sectionIds = courseSubSections?.map { it.id }.orEmpty() + val filteredStatuses = downloadedStateMap.filterKeys { it in sectionIds }.values + val downloadedState = when { + filteredStatuses.isEmpty() -> null + filteredStatuses.all { it.isDownloaded } -> DownloadedState.DOWNLOADED + filteredStatuses.any { it.isWaitingOrDownloading } -> DownloadedState.DOWNLOADING + else -> DownloadedState.NOT_DOWNLOADED + } Column(modifier = modifier .clip(MaterialTheme.appShapes.cardShape) diff --git a/course/src/main/java/org/openedx/course/presentation/ui/CourseVideosUI.kt b/course/src/main/java/org/openedx/course/presentation/ui/CourseVideosUI.kt index 7364ac9cf..7beb0f91d 100644 --- a/course/src/main/java/org/openedx/course/presentation/ui/CourseVideosUI.kt +++ b/course/src/main/java/org/openedx/course/presentation/ui/CourseVideosUI.kt @@ -125,20 +125,11 @@ fun CourseVideosScreen( } }, onDownloadClick = { blocksIds -> - blocksIds.forEach { blockId -> - if (viewModel.isBlockDownloading(blockId)) { - viewModel.courseRouter.navigateToDownloadQueue( - fm = fragmentManager, - viewModel.getDownloadableChildren(blockId) ?: arrayListOf() - ) - } else if (viewModel.isBlockDownloaded(blockId)) { - viewModel.removeDownloadModels(blockId) - } else { - viewModel.saveDownloadModels( - FileUtil(context).getExternalAppDir().path, blockId - ) - } - } + viewModel.downloadBlocks( + blocksIds = blocksIds, + fragmentManager = fragmentManager, + context = context + ) }, onDownloadAllClick = { isAllBlocksDownloadedOrDownloading -> viewModel.logBulkDownloadToggleEvent(!isAllBlocksDownloadedOrDownloading) 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 d0213a3e1..49f3b6120 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 @@ -1,5 +1,7 @@ package org.openedx.course.presentation.videos +import android.content.Context +import androidx.fragment.app.FragmentManager import androidx.lifecycle.viewModelScope import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow @@ -25,6 +27,7 @@ import org.openedx.core.system.notifier.CourseNotifier import org.openedx.core.system.notifier.CourseStructureUpdated import org.openedx.core.system.notifier.VideoNotifier import org.openedx.core.system.notifier.VideoQualityChanged +import org.openedx.core.utils.FileUtil import org.openedx.course.R import org.openedx.course.domain.interactor.CourseInteractor import org.openedx.course.presentation.CourseAnalytics @@ -210,4 +213,21 @@ class CourseVideoViewModel( } return resultBlocks.toList() } + + fun downloadBlocks(blocksIds: List, fragmentManager: FragmentManager, context: Context) { + blocksIds.forEach { blockId -> + if (isBlockDownloading(blockId)) { + courseRouter.navigateToDownloadQueue( + fm = fragmentManager, + getDownloadableChildren(blockId) ?: arrayListOf() + ) + } else if (isBlockDownloaded(blockId)) { + removeDownloadModels(blockId) + } else { + saveDownloadModels( + FileUtil(context).getExternalAppDir().path, blockId + ) + } + } + } }