From e4655529ae24e2169e2d27d654093a3f5f2d652e Mon Sep 17 00:00:00 2001 From: PavloNetrebchuk <141041606+PavloNetrebchuk@users.noreply.github.com> Date: Fri, 14 Feb 2025 15:42:56 +0200 Subject: [PATCH 01/24] fix: [FC-0078] RTL, user avatar and dark theme bugs (#426) * fix: color fixes * fix: user image rotation fix * fix: rtl support --- .../openedx/auth/presentation/ui/AuthUI.kt | 4 +- .../java/org/openedx/core/ui/ComposeCommon.kt | 3 +- core/src/main/res/drawable/core_ic_back.xml | 31 --------------- .../src/main/res/drawable/core_ic_forward.xml | 31 --------------- .../presentation/ChapterEndFragmentDialog.kt | 13 +++++-- .../outline/CourseOutlineScreen.kt | 7 ++-- .../course/presentation/ui/CourseUI.kt | 6 +-- .../course/presentation/ui/CourseVideosUI.kt | 4 +- .../unit/html/HtmlUnitFragment.kt | 4 +- .../presentation/DashboardGalleryView.kt | 13 +++---- .../presentation/ui/DiscussionUI.kt | 4 +- .../calendar/NewCalendarDialogFragment.kt | 3 +- .../presentation/edit/EditProfileFragment.kt | 38 ++----------------- .../profile/presentation/ui/SettingsUI.kt | 6 +-- .../video/VideoSettingsFragment.kt | 6 +-- .../whatsnew/presentation/ui/WhatsNewUI.kt | 7 +++- 16 files changed, 47 insertions(+), 133 deletions(-) delete mode 100644 core/src/main/res/drawable/core_ic_back.xml delete mode 100644 core/src/main/res/drawable/core_ic_forward.xml 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 ccd790512..61d8f7450 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 @@ -23,7 +23,7 @@ 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.ChevronRight +import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight import androidx.compose.material.icons.filled.ExpandMore import androidx.compose.material.icons.filled.Visibility import androidx.compose.material.icons.filled.VisibilityOff @@ -524,7 +524,7 @@ fun ExpandableText( } else { stringResource(id = R.string.auth_show_optional_fields) } - val icon = Icons.Filled.ChevronRight + val icon = Icons.AutoMirrored.Filled.KeyboardArrowRight Row( modifier = modifier 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 fbbead83e..c2fad664d 100644 --- a/core/src/main/java/org/openedx/core/ui/ComposeCommon.kt +++ b/core/src/main/java/org/openedx/core/ui/ComposeCommon.kt @@ -52,6 +52,7 @@ import androidx.compose.material.ScaffoldState import androidx.compose.material.Text import androidx.compose.material.TextFieldDefaults import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.filled.AccountCircle import androidx.compose.material.icons.filled.Close import androidx.compose.material.icons.filled.Info @@ -1163,7 +1164,7 @@ fun BackBtn( } ) { Icon( - painter = painterResource(id = R.drawable.core_ic_back), + imageVector = Icons.AutoMirrored.Filled.ArrowBack, contentDescription = stringResource(id = R.string.core_accessibility_btn_back), tint = tint ) diff --git a/core/src/main/res/drawable/core_ic_back.xml b/core/src/main/res/drawable/core_ic_back.xml deleted file mode 100644 index 912dc1200..000000000 --- a/core/src/main/res/drawable/core_ic_back.xml +++ /dev/null @@ -1,31 +0,0 @@ - - - - - - - - diff --git a/core/src/main/res/drawable/core_ic_forward.xml b/core/src/main/res/drawable/core_ic_forward.xml deleted file mode 100644 index 8c47ce201..000000000 --- a/core/src/main/res/drawable/core_ic_forward.xml +++ /dev/null @@ -1,31 +0,0 @@ - - - - - - - - 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 376f06c90..8f574cdb8 100644 --- a/course/src/main/java/org/openedx/course/presentation/ChapterEndFragmentDialog.kt +++ b/course/src/main/java/org/openedx/course/presentation/ChapterEndFragmentDialog.kt @@ -24,12 +24,13 @@ 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.automirrored.filled.ArrowForward +import androidx.compose.material.icons.filled.ArrowDownward import androidx.compose.material.icons.filled.Close 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.draw.rotate import androidx.compose.ui.platform.ComposeView import androidx.compose.ui.platform.LocalConfiguration import androidx.compose.ui.platform.ViewCompositionStrategy @@ -153,6 +154,11 @@ private fun ChapterEndDialogScreen( onProceedButtonClick: () -> Unit, onCancelButtonClick: () -> Unit ) { + val nextSectionButtonIcon = if (isVerticalNavigation) { + Icons.Default.ArrowDownward + } else { + Icons.AutoMirrored.Filled.ArrowForward + } Card( modifier = Modifier .fillMaxWidth(fraction = 0.95f) @@ -208,10 +214,9 @@ private fun ChapterEndDialogScreen( content = { TextIcon( text = stringResource(id = R.string.course_next_section), - painter = painterResource(org.openedx.core.R.drawable.core_ic_forward), + icon = nextSectionButtonIcon, color = MaterialTheme.appColors.primaryButtonText, textStyle = MaterialTheme.appTypography.labelLarge, - iconModifier = Modifier.rotate(if (isVerticalNavigation) 90f else 0f) ) }, onClick = onProceedButtonClick @@ -324,7 +329,7 @@ private fun ChapterEndDialogScreenLandscape( content = { TextIcon( text = stringResource(id = R.string.course_next_section), - painter = painterResource(org.openedx.core.R.drawable.core_ic_forward), + icon = Icons.AutoMirrored.Filled.ArrowForward, color = MaterialTheme.appColors.primaryButtonText, textStyle = MaterialTheme.appTypography.labelLarge ) 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 27c4594da..3afb802dc 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 @@ -23,6 +23,8 @@ import androidx.compose.material.MaterialTheme import androidx.compose.material.Scaffold import androidx.compose.material.Surface import androidx.compose.material.Text +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowForward import androidx.compose.material.rememberScaffoldState import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect @@ -75,7 +77,6 @@ import org.openedx.foundation.presentation.WindowSize import org.openedx.foundation.presentation.WindowType import org.openedx.foundation.presentation.windowSizeValue import java.util.Date -import org.openedx.core.R as CoreR @Composable fun CourseOutlineScreen( @@ -397,7 +398,7 @@ private fun ResumeCourse( content = { TextIcon( text = stringResource(id = R.string.course_resume), - painter = painterResource(id = CoreR.drawable.core_ic_forward), + icon = Icons.AutoMirrored.Filled.ArrowForward, color = MaterialTheme.appColors.primaryButtonText, textStyle = MaterialTheme.appTypography.labelLarge ) @@ -456,7 +457,7 @@ private fun ResumeCourseTablet( content = { TextIcon( text = stringResource(id = R.string.course_resume), - painter = painterResource(id = CoreR.drawable.core_ic_forward), + icon = Icons.AutoMirrored.Filled.ArrowForward, 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 2598ad8ac..1a6cd60a7 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 @@ -44,7 +44,7 @@ import androidx.compose.material.Snackbar import androidx.compose.material.Surface import androidx.compose.material.Text import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.ChevronRight +import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight import androidx.compose.material.icons.filled.Close import androidx.compose.material.icons.filled.CloudDone import androidx.compose.material.icons.outlined.CloudDownload @@ -305,7 +305,7 @@ fun CardArrow( degrees: Float, ) { Icon( - imageVector = Icons.Filled.ChevronRight, + imageVector = Icons.AutoMirrored.Filled.KeyboardArrowRight, tint = MaterialTheme.appColors.textDark, contentDescription = "Expandable Arrow", modifier = Modifier.rotate(degrees), @@ -822,7 +822,7 @@ fun CourseSubSectionItem( Spacer(modifier = Modifier.width(16.dp)) if (isAssignmentEnable) { Icon( - imageVector = Icons.Filled.ChevronRight, + imageVector = Icons.AutoMirrored.Filled.KeyboardArrowRight, tint = MaterialTheme.appColors.onSurface, contentDescription = null ) 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 ff20ec55d..5e2c0b8fa 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 @@ -31,7 +31,7 @@ import androidx.compose.material.SwitchDefaults import androidx.compose.material.Text import androidx.compose.material.TextButton import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.ChevronRight +import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight import androidx.compose.material.icons.outlined.Settings import androidx.compose.material.icons.outlined.Videocam import androidx.compose.material.rememberScaffoldState @@ -598,7 +598,7 @@ private fun AllVideosDownloadItem( Icon( modifier = Modifier .padding(end = 16.dp), - imageVector = Icons.Filled.ChevronRight, + imageVector = Icons.AutoMirrored.Filled.KeyboardArrowRight, tint = MaterialTheme.appColors.onSurface, contentDescription = "Expandable Arrow" ) 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 ac0011c2f..471918622 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 @@ -38,7 +38,6 @@ 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.platform.ComposeView import androidx.compose.ui.platform.LocalConfiguration import androidx.compose.ui.platform.LocalContext @@ -179,13 +178,12 @@ fun HtmlUnitView( Surface( modifier = Modifier .clip(RoundedCornerShape(topStart = 24.dp, topEnd = 24.dp)), - color = Color.White + color = MaterialTheme.colors.background ) { Box( modifier = Modifier .fillMaxSize() .padding(bottom = bottomPadding) - .background(Color.White) .then(border), contentAlignment = Alignment.TopCenter ) { 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 2c44c2c61..f95d6dc65 100644 --- a/dashboard/src/main/java/org/openedx/courses/presentation/DashboardGalleryView.kt +++ b/dashboard/src/main/java/org/openedx/courses/presentation/DashboardGalleryView.kt @@ -33,8 +33,7 @@ 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.automirrored.filled.KeyboardArrowRight import androidx.compose.material.icons.filled.School import androidx.compose.material.icons.filled.Warning import androidx.compose.material.pullrefresh.PullRefreshIndicator @@ -347,7 +346,7 @@ private fun SecondaryCourses( 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, + icon = Icons.AutoMirrored.Filled.KeyboardArrowRight, color = MaterialTheme.appColors.textDark, iconModifier = Modifier.size(22.dp), onClick = onViewAllClick @@ -512,8 +511,8 @@ private fun AssignmentItem( } } Icon( - modifier = Modifier.size(16.dp), - imageVector = Icons.AutoMirrored.Filled.ArrowForwardIos, + modifier = Modifier.size(22.dp), + imageVector = Icons.AutoMirrored.Filled.KeyboardArrowRight, tint = MaterialTheme.appColors.textDark, contentDescription = null ) @@ -690,8 +689,8 @@ private fun ResumeButton( } } Icon( - modifier = Modifier.size(16.dp), - imageVector = Icons.AutoMirrored.Filled.ArrowForwardIos, + modifier = Modifier.size(22.dp), + imageVector = Icons.AutoMirrored.Filled.KeyboardArrowRight, tint = MaterialTheme.appColors.primaryButtonText, contentDescription = null ) 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 376e3118e..64dd4dcd0 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 @@ -27,8 +27,8 @@ import androidx.compose.material.MaterialTheme import androidx.compose.material.Surface import androidx.compose.material.Text import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight import androidx.compose.material.icons.automirrored.outlined.HelpOutline -import androidx.compose.material.icons.filled.ChevronRight import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.ExperimentalComposeUiApi @@ -661,7 +661,7 @@ fun TopicItem( color = MaterialTheme.appColors.textPrimary ) Icon( - imageVector = Icons.Filled.ChevronRight, + imageVector = Icons.AutoMirrored.Filled.KeyboardArrowRight, tint = MaterialTheme.appColors.primary, contentDescription = "Expandable Arrow" ) 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 e7bbecae5..857af17d0 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 @@ -265,7 +265,8 @@ private fun CalendarTitleTextField( onValueChanged(it.text.trim()) }, colors = TextFieldDefaults.outlinedTextFieldColors( - unfocusedBorderColor = MaterialTheme.appColors.textFieldBorder + unfocusedBorderColor = MaterialTheme.appColors.textFieldBorder, + textColor = MaterialTheme.appColors.textPrimary ), shape = MaterialTheme.appShapes.textFieldShape, placeholder = { 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 62727f822..8f9a3fd14 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 @@ -6,8 +6,6 @@ import android.content.res.Configuration import android.content.res.Configuration.UI_MODE_NIGHT_YES import android.graphics.Bitmap import android.graphics.ImageDecoder -import android.graphics.Matrix -import android.media.ExifInterface import android.net.Uri import android.os.Build import android.os.Bundle @@ -236,8 +234,6 @@ class EditProfileFragment : Fragment() { @Suppress("DEPRECATION") private fun cropImage(uri: Uri): Uri { - val matrix = Matrix() - matrix.postRotate(getImageOrientation(uri).toFloat()) val originalBitmap = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { ImageDecoder.decodeBitmap( ImageDecoder.createSource( @@ -248,26 +244,17 @@ class EditProfileFragment : Fragment() { } else { MediaStore.Images.Media.getBitmap(requireContext().contentResolver, uri) } - val rotatedBitmap = Bitmap.createBitmap( - originalBitmap, - 0, - 0, - originalBitmap.width, - originalBitmap.height, - matrix, - true - ) val newFile = File.createTempFile( "Image_${System.currentTimeMillis()}", ".jpg", requireContext().getExternalFilesDir(Environment.DIRECTORY_PICTURES) ) - val ratio: Float = rotatedBitmap.width.toFloat() / TARGET_IMAGE_WIDTH + val ratio: Float = originalBitmap.width.toFloat() / TARGET_IMAGE_WIDTH val newBitmap = Bitmap.createScaledBitmap( - rotatedBitmap, + originalBitmap, TARGET_IMAGE_WIDTH, - (rotatedBitmap.height.toFloat() / ratio).toInt(), + (originalBitmap.height.toFloat() / ratio).toInt(), false ) val bos = ByteArrayOutputStream() @@ -285,28 +272,9 @@ class EditProfileFragment : Fragment() { )!! } - private fun getImageOrientation(uri: Uri): Int { - var rotation = 0 - val exif = ExifInterface(requireActivity().contentResolver.openInputStream(uri)!!) - when ( - exif.getAttributeInt( - ExifInterface.TAG_ORIENTATION, - ExifInterface.ORIENTATION_NORMAL - ) - ) { - ExifInterface.ORIENTATION_ROTATE_270 -> rotation = ORIENTATION_ROTATE_270 - ExifInterface.ORIENTATION_ROTATE_180 -> rotation = ORIENTATION_ROTATE_180 - ExifInterface.ORIENTATION_ROTATE_90 -> rotation = ORIENTATION_ROTATE_90 - } - return rotation - } - companion object { private const val ARG_ACCOUNT = "argAccount" const val LEAVE_PROFILE_WIDTH_FACTOR = 0.7f - private const val ORIENTATION_ROTATE_270 = 270 - private const val ORIENTATION_ROTATE_180 = 180 - private const val ORIENTATION_ROTATE_90 = 90 private const val IMAGE_QUALITY = 90 private const val TARGET_IMAGE_WIDTH = 500 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 f4811135a..7a41a916e 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 @@ -11,7 +11,7 @@ import androidx.compose.material.Icon import androidx.compose.material.MaterialTheme import androidx.compose.material.Text import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.automirrored.filled.ArrowForwardIos +import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight import androidx.compose.material.icons.automirrored.filled.OpenInNew import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment @@ -32,7 +32,7 @@ fun SettingsItem( val icon = if (external) { Icons.AutoMirrored.Filled.OpenInNew } else { - Icons.AutoMirrored.Filled.ArrowForwardIos + Icons.AutoMirrored.Filled.KeyboardArrowRight } Row( Modifier @@ -57,7 +57,7 @@ fun SettingsItem( color = MaterialTheme.appColors.textPrimary ) Icon( - modifier = Modifier.size(16.dp), + modifier = Modifier.size(22.dp), imageVector = icon, contentDescription = null ) diff --git a/profile/src/main/java/org/openedx/profile/presentation/video/VideoSettingsFragment.kt b/profile/src/main/java/org/openedx/profile/presentation/video/VideoSettingsFragment.kt index d9b434130..5cbfc0635 100644 --- a/profile/src/main/java/org/openedx/profile/presentation/video/VideoSettingsFragment.kt +++ b/profile/src/main/java/org/openedx/profile/presentation/video/VideoSettingsFragment.kt @@ -25,7 +25,7 @@ import androidx.compose.material.Switch import androidx.compose.material.SwitchDefaults import androidx.compose.material.Text import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.ChevronRight +import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight import androidx.compose.material.rememberScaffoldState import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue @@ -253,7 +253,7 @@ private fun VideoSettingsScreen( ) } Icon( - imageVector = Icons.Filled.ChevronRight, + imageVector = Icons.AutoMirrored.Filled.KeyboardArrowRight, tint = MaterialTheme.appColors.onSurface, contentDescription = stringResource(CoreR.string.core_accessibility_expandable_arrow) ) @@ -284,7 +284,7 @@ private fun VideoSettingsScreen( ) } Icon( - imageVector = Icons.Filled.ChevronRight, + imageVector = Icons.AutoMirrored.Filled.KeyboardArrowRight, tint = MaterialTheme.appColors.onSurface, contentDescription = stringResource(CoreR.string.core_accessibility_expandable_arrow) ) 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 9c34603f1..d6d7c619d 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 @@ -23,6 +23,9 @@ import androidx.compose.material.Icon import androidx.compose.material.MaterialTheme import androidx.compose.material.OutlinedButton import androidx.compose.material.Text +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.automirrored.filled.ArrowForward import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment @@ -185,7 +188,7 @@ fun PrevButton( horizontalArrangement = Arrangement.Center ) { Icon( - painter = painterResource(id = org.openedx.core.R.drawable.core_ic_back), + imageVector = Icons.AutoMirrored.Filled.ArrowBack, contentDescription = null, tint = MaterialTheme.appColors.primary ) @@ -235,7 +238,7 @@ fun NextFinishButton( ) Spacer(Modifier.width(8.dp)) Icon( - painter = painterResource(id = org.openedx.core.R.drawable.core_ic_forward), + imageVector = Icons.AutoMirrored.Filled.ArrowForward, contentDescription = null, tint = MaterialTheme.appColors.primaryButtonText ) From 19eb8e3bde41ac312dda2c8aa4a8f9b108478fab Mon Sep 17 00:00:00 2001 From: Ahsan Arif Date: Tue, 4 Mar 2025 16:18:05 +0500 Subject: [PATCH 02/24] Performance: Optimized data loading of course (#416) * perf: optimised data loading on dashboard and course details * fix: course unit tests * fix: course load exception and removed ui result class * fix: lint checks * chore: moved to flow.catch from try/catch in course outline and container viewmodels --- app/build.gradle | 4 + .../org.openedx.app.room.AppDatabase/1.json | 772 ++++++++++++++ .../org.openedx.app.room.AppDatabase/2.json | 978 ++++++++++++++++++ .../java/org/openedx/app/room/AppDatabase.kt | 13 +- .../org/openedx/app/room/DatabaseManager.kt | 1 + .../room/CourseEnrollmentDetailsEntity.kt | 84 ++ .../openedx/core/domain/model/Certificate.kt | 3 + .../core/domain/model/CourseAccessDetails.kt | 14 +- .../domain/model/CourseEnrollmentDetails.kt | 12 + .../core/domain/model/CourseInfoOverview.kt | 17 +- .../model/CourseSharingUtmParameters.kt | 9 +- .../core/domain/model/CoursewareAccess.kt | 13 +- .../core/domain/model/EnrollmentDetails.kt | 12 +- .../org/openedx/core/domain/model/Media.kt | 35 +- .../openedx/core/extension/CoroutineExt.kt | 14 + .../data/repository/CourseRepository.kt | 94 +- .../openedx/course/data/storage/CourseDao.kt | 10 + .../domain/interactor/CourseInteractor.kt | 17 + .../container/CourseContainerViewModel.kt | 60 +- .../outline/CourseOutlineViewModel.kt | 78 +- .../container/CourseContainerViewModelTest.kt | 22 +- .../outline/CourseOutlineViewModelTest.kt | 443 ++++---- .../AllEnrolledCoursesViewModel.kt | 19 +- .../presentation/DashboardGalleryViewModel.kt | 25 +- 24 files changed, 2426 insertions(+), 323 deletions(-) create mode 100644 app/schemas/org.openedx.app.room.AppDatabase/1.json create mode 100644 app/schemas/org.openedx.app.room.AppDatabase/2.json create mode 100644 core/src/main/java/org/openedx/core/data/model/room/CourseEnrollmentDetailsEntity.kt create mode 100644 core/src/main/java/org/openedx/core/extension/CoroutineExt.kt diff --git a/app/build.gradle b/app/build.gradle index baabb18d2..e863910ef 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -180,3 +180,7 @@ private def setupFirebaseConfigFields(buildType) { buildType.manifestPlaceholders = [fcmEnabled: firebaseEnabled && cloudMessagingEnabled] } + +ksp { + arg("room.schemaLocation", "$projectDir/schemas") +} diff --git a/app/schemas/org.openedx.app.room.AppDatabase/1.json b/app/schemas/org.openedx.app.room.AppDatabase/1.json new file mode 100644 index 000000000..c249fa741 --- /dev/null +++ b/app/schemas/org.openedx.app.room.AppDatabase/1.json @@ -0,0 +1,772 @@ +{ + "formatVersion": 1, + "database": { + "version": 1, + "identityHash": "bcac519e74e751a75f3e6fa5d39ac5a3", + "entities": [ + { + "tableName": "course_discovery_table", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `blocksUrl` TEXT NOT NULL, `courseId` TEXT NOT NULL, `effort` TEXT NOT NULL, `enrollmentStart` TEXT NOT NULL, `enrollmentEnd` TEXT NOT NULL, `hidden` INTEGER NOT NULL, `invitationOnly` INTEGER NOT NULL, `mobileAvailable` INTEGER NOT NULL, `name` TEXT NOT NULL, `number` TEXT NOT NULL, `org` TEXT NOT NULL, `pacing` TEXT NOT NULL, `shortDescription` TEXT NOT NULL, `start` TEXT NOT NULL, `end` TEXT NOT NULL, `startDisplay` TEXT NOT NULL, `startType` TEXT NOT NULL, `overview` TEXT NOT NULL, `isEnrolled` INTEGER NOT NULL, `bannerImage` TEXT, `courseImage` TEXT, `courseVideo` TEXT, `image` TEXT, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "blocksUrl", + "columnName": "blocksUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "courseId", + "columnName": "courseId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "effort", + "columnName": "effort", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "enrollmentStart", + "columnName": "enrollmentStart", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "enrollmentEnd", + "columnName": "enrollmentEnd", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "hidden", + "columnName": "hidden", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "invitationOnly", + "columnName": "invitationOnly", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "mobileAvailable", + "columnName": "mobileAvailable", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "number", + "columnName": "number", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "org", + "columnName": "org", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "pacing", + "columnName": "pacing", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "shortDescription", + "columnName": "shortDescription", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "start", + "columnName": "start", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "end", + "columnName": "end", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "startDisplay", + "columnName": "startDisplay", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "startType", + "columnName": "startType", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "overview", + "columnName": "overview", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "isEnrolled", + "columnName": "isEnrolled", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "media.bannerImage", + "columnName": "bannerImage", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "media.courseImage", + "columnName": "courseImage", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "media.courseVideo", + "columnName": "courseVideo", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "media.image", + "columnName": "image", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "course_enrolled_table", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`courseId` TEXT NOT NULL, `auditAccessExpires` TEXT NOT NULL, `created` TEXT NOT NULL, `mode` TEXT NOT NULL, `isActive` INTEGER NOT NULL, `id` TEXT NOT NULL, `name` TEXT NOT NULL, `number` TEXT NOT NULL, `org` TEXT NOT NULL, `start` TEXT NOT NULL, `startDisplay` TEXT NOT NULL, `startType` TEXT NOT NULL, `end` TEXT NOT NULL, `dynamicUpgradeDeadline` TEXT NOT NULL, `subscriptionId` TEXT NOT NULL, `course_image_link` TEXT NOT NULL, `courseAbout` TEXT NOT NULL, `courseUpdates` TEXT NOT NULL, `courseHandouts` TEXT NOT NULL, `discussionUrl` TEXT NOT NULL, `videoOutline` TEXT NOT NULL, `isSelfPaced` INTEGER NOT NULL, `hasAccess` INTEGER, `errorCode` TEXT, `developerMessage` TEXT, `userMessage` TEXT, `additionalContextUserMessage` TEXT, `userFragment` TEXT, `bannerImage` TEXT, `courseImage` TEXT, `courseVideo` TEXT, `image` TEXT, `facebook` TEXT NOT NULL, `twitter` TEXT NOT NULL, `certificateURL` TEXT, `assignments_completed` INTEGER NOT NULL, `total_assignments_count` INTEGER NOT NULL, `lastVisitedModuleId` TEXT, `lastVisitedModulePath` TEXT, `lastVisitedBlockId` TEXT, `lastVisitedUnitDisplayName` TEXT, `futureAssignments` TEXT, `pastAssignments` TEXT, PRIMARY KEY(`courseId`))", + "fields": [ + { + "fieldPath": "courseId", + "columnName": "courseId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "auditAccessExpires", + "columnName": "auditAccessExpires", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "created", + "columnName": "created", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "mode", + "columnName": "mode", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "isActive", + "columnName": "isActive", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "course.id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "course.name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "course.number", + "columnName": "number", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "course.org", + "columnName": "org", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "course.start", + "columnName": "start", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "course.startDisplay", + "columnName": "startDisplay", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "course.startType", + "columnName": "startType", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "course.end", + "columnName": "end", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "course.dynamicUpgradeDeadline", + "columnName": "dynamicUpgradeDeadline", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "course.subscriptionId", + "columnName": "subscriptionId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "course.courseImage", + "columnName": "course_image_link", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "course.courseAbout", + "columnName": "courseAbout", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "course.courseUpdates", + "columnName": "courseUpdates", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "course.courseHandouts", + "columnName": "courseHandouts", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "course.discussionUrl", + "columnName": "discussionUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "course.videoOutline", + "columnName": "videoOutline", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "course.isSelfPaced", + "columnName": "isSelfPaced", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "course.coursewareAccess.hasAccess", + "columnName": "hasAccess", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "course.coursewareAccess.errorCode", + "columnName": "errorCode", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "course.coursewareAccess.developerMessage", + "columnName": "developerMessage", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "course.coursewareAccess.userMessage", + "columnName": "userMessage", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "course.coursewareAccess.additionalContextUserMessage", + "columnName": "additionalContextUserMessage", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "course.coursewareAccess.userFragment", + "columnName": "userFragment", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "course.media.bannerImage", + "columnName": "bannerImage", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "course.media.courseImage", + "columnName": "courseImage", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "course.media.courseVideo", + "columnName": "courseVideo", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "course.media.image", + "columnName": "image", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "course.courseSharingUtmParameters.facebook", + "columnName": "facebook", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "course.courseSharingUtmParameters.twitter", + "columnName": "twitter", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "certificate.certificateURL", + "columnName": "certificateURL", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "progress.assignmentsCompleted", + "columnName": "assignments_completed", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "progress.totalAssignmentsCount", + "columnName": "total_assignments_count", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "courseStatus.lastVisitedModuleId", + "columnName": "lastVisitedModuleId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "courseStatus.lastVisitedModulePath", + "columnName": "lastVisitedModulePath", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "courseStatus.lastVisitedBlockId", + "columnName": "lastVisitedBlockId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "courseStatus.lastVisitedUnitDisplayName", + "columnName": "lastVisitedUnitDisplayName", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "courseAssignments.futureAssignments", + "columnName": "futureAssignments", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "courseAssignments.pastAssignments", + "columnName": "pastAssignments", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "courseId" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "course_structure_table", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`root` TEXT NOT NULL, `id` TEXT NOT NULL, `blocks` TEXT NOT NULL, `name` TEXT NOT NULL, `number` TEXT NOT NULL, `org` TEXT NOT NULL, `start` TEXT, `startDisplay` TEXT NOT NULL, `startType` TEXT NOT NULL, `end` TEXT, `isSelfPaced` INTEGER NOT NULL, `hasAccess` INTEGER, `errorCode` TEXT, `developerMessage` TEXT, `userMessage` TEXT, `additionalContextUserMessage` TEXT, `userFragment` TEXT, `bannerImage` TEXT, `courseImage` TEXT, `courseVideo` TEXT, `image` TEXT, `certificateURL` TEXT, `assignments_completed` INTEGER NOT NULL, `total_assignments_count` INTEGER NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "root", + "columnName": "root", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "blocks", + "columnName": "blocks", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "number", + "columnName": "number", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "org", + "columnName": "org", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "start", + "columnName": "start", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "startDisplay", + "columnName": "startDisplay", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "startType", + "columnName": "startType", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "end", + "columnName": "end", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "isSelfPaced", + "columnName": "isSelfPaced", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "coursewareAccess.hasAccess", + "columnName": "hasAccess", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "coursewareAccess.errorCode", + "columnName": "errorCode", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "coursewareAccess.developerMessage", + "columnName": "developerMessage", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "coursewareAccess.userMessage", + "columnName": "userMessage", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "coursewareAccess.additionalContextUserMessage", + "columnName": "additionalContextUserMessage", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "coursewareAccess.userFragment", + "columnName": "userFragment", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "media.bannerImage", + "columnName": "bannerImage", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "media.courseImage", + "columnName": "courseImage", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "media.courseVideo", + "columnName": "courseVideo", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "media.image", + "columnName": "image", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "certificate.certificateURL", + "columnName": "certificateURL", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "progress.assignmentsCompleted", + "columnName": "assignments_completed", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "progress.totalAssignmentsCount", + "columnName": "total_assignments_count", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "download_model", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `title` TEXT NOT NULL, `courseId` TEXT NOT NULL, `size` INTEGER NOT NULL, `path` TEXT NOT NULL, `url` TEXT NOT NULL, `type` TEXT NOT NULL, `downloadedState` TEXT NOT NULL, `lastModified` TEXT, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "courseId", + "columnName": "courseId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "size", + "columnName": "size", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "path", + "columnName": "path", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "downloadedState", + "columnName": "downloadedState", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lastModified", + "columnName": "lastModified", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "offline_x_block_progress_table", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `courseId` TEXT NOT NULL, `url` TEXT NOT NULL, `type` TEXT NOT NULL, `data` TEXT NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "blockId", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "courseId", + "columnName": "courseId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "jsonProgress.url", + "columnName": "url", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "jsonProgress.type", + "columnName": "type", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "jsonProgress.data", + "columnName": "data", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "course_calendar_event_table", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`event_id` INTEGER NOT NULL, `course_id` TEXT NOT NULL, PRIMARY KEY(`event_id`))", + "fields": [ + { + "fieldPath": "eventId", + "columnName": "event_id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "courseId", + "columnName": "course_id", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "event_id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "course_calendar_state_table", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`course_id` TEXT NOT NULL, `checksum` INTEGER NOT NULL, `is_course_sync_enabled` INTEGER NOT NULL, PRIMARY KEY(`course_id`))", + "fields": [ + { + "fieldPath": "courseId", + "columnName": "course_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "checksum", + "columnName": "checksum", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isCourseSyncEnabled", + "columnName": "is_course_sync_enabled", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "course_id" + ] + }, + "indices": [], + "foreignKeys": [] + } + ], + "views": [], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'bcac519e74e751a75f3e6fa5d39ac5a3')" + ] + } +} \ No newline at end of file diff --git a/app/schemas/org.openedx.app.room.AppDatabase/2.json b/app/schemas/org.openedx.app.room.AppDatabase/2.json new file mode 100644 index 000000000..002abc547 --- /dev/null +++ b/app/schemas/org.openedx.app.room.AppDatabase/2.json @@ -0,0 +1,978 @@ +{ + "formatVersion": 1, + "database": { + "version": 2, + "identityHash": "ed545aec6739ec7692c4bb72179331c4", + "entities": [ + { + "tableName": "course_discovery_table", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `blocksUrl` TEXT NOT NULL, `courseId` TEXT NOT NULL, `effort` TEXT NOT NULL, `enrollmentStart` TEXT NOT NULL, `enrollmentEnd` TEXT NOT NULL, `hidden` INTEGER NOT NULL, `invitationOnly` INTEGER NOT NULL, `mobileAvailable` INTEGER NOT NULL, `name` TEXT NOT NULL, `number` TEXT NOT NULL, `org` TEXT NOT NULL, `pacing` TEXT NOT NULL, `shortDescription` TEXT NOT NULL, `start` TEXT NOT NULL, `end` TEXT NOT NULL, `startDisplay` TEXT NOT NULL, `startType` TEXT NOT NULL, `overview` TEXT NOT NULL, `isEnrolled` INTEGER NOT NULL, `bannerImage` TEXT, `courseImage` TEXT, `courseVideo` TEXT, `image` TEXT, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "blocksUrl", + "columnName": "blocksUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "courseId", + "columnName": "courseId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "effort", + "columnName": "effort", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "enrollmentStart", + "columnName": "enrollmentStart", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "enrollmentEnd", + "columnName": "enrollmentEnd", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "hidden", + "columnName": "hidden", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "invitationOnly", + "columnName": "invitationOnly", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "mobileAvailable", + "columnName": "mobileAvailable", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "number", + "columnName": "number", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "org", + "columnName": "org", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "pacing", + "columnName": "pacing", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "shortDescription", + "columnName": "shortDescription", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "start", + "columnName": "start", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "end", + "columnName": "end", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "startDisplay", + "columnName": "startDisplay", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "startType", + "columnName": "startType", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "overview", + "columnName": "overview", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "isEnrolled", + "columnName": "isEnrolled", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "media.bannerImage", + "columnName": "bannerImage", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "media.courseImage", + "columnName": "courseImage", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "media.courseVideo", + "columnName": "courseVideo", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "media.image", + "columnName": "image", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "course_enrolled_table", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`courseId` TEXT NOT NULL, `auditAccessExpires` TEXT NOT NULL, `created` TEXT NOT NULL, `mode` TEXT NOT NULL, `isActive` INTEGER NOT NULL, `id` TEXT NOT NULL, `name` TEXT NOT NULL, `number` TEXT NOT NULL, `org` TEXT NOT NULL, `start` TEXT NOT NULL, `startDisplay` TEXT NOT NULL, `startType` TEXT NOT NULL, `end` TEXT NOT NULL, `dynamicUpgradeDeadline` TEXT NOT NULL, `subscriptionId` TEXT NOT NULL, `course_image_link` TEXT NOT NULL, `courseAbout` TEXT NOT NULL, `courseUpdates` TEXT NOT NULL, `courseHandouts` TEXT NOT NULL, `discussionUrl` TEXT NOT NULL, `videoOutline` TEXT NOT NULL, `isSelfPaced` INTEGER NOT NULL, `hasAccess` INTEGER, `errorCode` TEXT, `developerMessage` TEXT, `userMessage` TEXT, `additionalContextUserMessage` TEXT, `userFragment` TEXT, `bannerImage` TEXT, `courseImage` TEXT, `courseVideo` TEXT, `image` TEXT, `facebook` TEXT NOT NULL, `twitter` TEXT NOT NULL, `certificateURL` TEXT, `assignments_completed` INTEGER NOT NULL, `total_assignments_count` INTEGER NOT NULL, `lastVisitedModuleId` TEXT, `lastVisitedModulePath` TEXT, `lastVisitedBlockId` TEXT, `lastVisitedUnitDisplayName` TEXT, `futureAssignments` TEXT, `pastAssignments` TEXT, PRIMARY KEY(`courseId`))", + "fields": [ + { + "fieldPath": "courseId", + "columnName": "courseId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "auditAccessExpires", + "columnName": "auditAccessExpires", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "created", + "columnName": "created", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "mode", + "columnName": "mode", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "isActive", + "columnName": "isActive", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "course.id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "course.name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "course.number", + "columnName": "number", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "course.org", + "columnName": "org", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "course.start", + "columnName": "start", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "course.startDisplay", + "columnName": "startDisplay", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "course.startType", + "columnName": "startType", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "course.end", + "columnName": "end", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "course.dynamicUpgradeDeadline", + "columnName": "dynamicUpgradeDeadline", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "course.subscriptionId", + "columnName": "subscriptionId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "course.courseImage", + "columnName": "course_image_link", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "course.courseAbout", + "columnName": "courseAbout", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "course.courseUpdates", + "columnName": "courseUpdates", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "course.courseHandouts", + "columnName": "courseHandouts", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "course.discussionUrl", + "columnName": "discussionUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "course.videoOutline", + "columnName": "videoOutline", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "course.isSelfPaced", + "columnName": "isSelfPaced", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "course.coursewareAccess.hasAccess", + "columnName": "hasAccess", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "course.coursewareAccess.errorCode", + "columnName": "errorCode", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "course.coursewareAccess.developerMessage", + "columnName": "developerMessage", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "course.coursewareAccess.userMessage", + "columnName": "userMessage", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "course.coursewareAccess.additionalContextUserMessage", + "columnName": "additionalContextUserMessage", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "course.coursewareAccess.userFragment", + "columnName": "userFragment", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "course.media.bannerImage", + "columnName": "bannerImage", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "course.media.courseImage", + "columnName": "courseImage", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "course.media.courseVideo", + "columnName": "courseVideo", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "course.media.image", + "columnName": "image", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "course.courseSharingUtmParameters.facebook", + "columnName": "facebook", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "course.courseSharingUtmParameters.twitter", + "columnName": "twitter", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "certificate.certificateURL", + "columnName": "certificateURL", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "progress.assignmentsCompleted", + "columnName": "assignments_completed", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "progress.totalAssignmentsCount", + "columnName": "total_assignments_count", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "courseStatus.lastVisitedModuleId", + "columnName": "lastVisitedModuleId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "courseStatus.lastVisitedModulePath", + "columnName": "lastVisitedModulePath", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "courseStatus.lastVisitedBlockId", + "columnName": "lastVisitedBlockId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "courseStatus.lastVisitedUnitDisplayName", + "columnName": "lastVisitedUnitDisplayName", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "courseAssignments.futureAssignments", + "columnName": "futureAssignments", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "courseAssignments.pastAssignments", + "columnName": "pastAssignments", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "courseId" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "course_structure_table", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`root` TEXT NOT NULL, `id` TEXT NOT NULL, `blocks` TEXT NOT NULL, `name` TEXT NOT NULL, `number` TEXT NOT NULL, `org` TEXT NOT NULL, `start` TEXT, `startDisplay` TEXT NOT NULL, `startType` TEXT NOT NULL, `end` TEXT, `isSelfPaced` INTEGER NOT NULL, `hasAccess` INTEGER, `errorCode` TEXT, `developerMessage` TEXT, `userMessage` TEXT, `additionalContextUserMessage` TEXT, `userFragment` TEXT, `bannerImage` TEXT, `courseImage` TEXT, `courseVideo` TEXT, `image` TEXT, `certificateURL` TEXT, `assignments_completed` INTEGER NOT NULL, `total_assignments_count` INTEGER NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "root", + "columnName": "root", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "blocks", + "columnName": "blocks", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "number", + "columnName": "number", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "org", + "columnName": "org", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "start", + "columnName": "start", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "startDisplay", + "columnName": "startDisplay", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "startType", + "columnName": "startType", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "end", + "columnName": "end", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "isSelfPaced", + "columnName": "isSelfPaced", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "coursewareAccess.hasAccess", + "columnName": "hasAccess", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "coursewareAccess.errorCode", + "columnName": "errorCode", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "coursewareAccess.developerMessage", + "columnName": "developerMessage", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "coursewareAccess.userMessage", + "columnName": "userMessage", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "coursewareAccess.additionalContextUserMessage", + "columnName": "additionalContextUserMessage", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "coursewareAccess.userFragment", + "columnName": "userFragment", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "media.bannerImage", + "columnName": "bannerImage", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "media.courseImage", + "columnName": "courseImage", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "media.courseVideo", + "columnName": "courseVideo", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "media.image", + "columnName": "image", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "certificate.certificateURL", + "columnName": "certificateURL", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "progress.assignmentsCompleted", + "columnName": "assignments_completed", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "progress.totalAssignmentsCount", + "columnName": "total_assignments_count", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "download_model", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `title` TEXT NOT NULL, `courseId` TEXT NOT NULL, `size` INTEGER NOT NULL, `path` TEXT NOT NULL, `url` TEXT NOT NULL, `type` TEXT NOT NULL, `downloadedState` TEXT NOT NULL, `lastModified` TEXT, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "courseId", + "columnName": "courseId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "size", + "columnName": "size", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "path", + "columnName": "path", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "downloadedState", + "columnName": "downloadedState", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lastModified", + "columnName": "lastModified", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "offline_x_block_progress_table", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `courseId` TEXT NOT NULL, `url` TEXT NOT NULL, `type` TEXT NOT NULL, `data` TEXT NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "blockId", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "courseId", + "columnName": "courseId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "jsonProgress.url", + "columnName": "url", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "jsonProgress.type", + "columnName": "type", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "jsonProgress.data", + "columnName": "data", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "course_calendar_event_table", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`event_id` INTEGER NOT NULL, `course_id` TEXT NOT NULL, PRIMARY KEY(`event_id`))", + "fields": [ + { + "fieldPath": "eventId", + "columnName": "event_id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "courseId", + "columnName": "course_id", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "event_id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "course_calendar_state_table", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`course_id` TEXT NOT NULL, `checksum` INTEGER NOT NULL, `is_course_sync_enabled` INTEGER NOT NULL, PRIMARY KEY(`course_id`))", + "fields": [ + { + "fieldPath": "courseId", + "columnName": "course_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "checksum", + "columnName": "checksum", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isCourseSyncEnabled", + "columnName": "is_course_sync_enabled", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "course_id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "course_enrollment_details_table", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `courseUpdates` TEXT NOT NULL, `courseHandouts` TEXT NOT NULL, `discussionUrl` TEXT NOT NULL, `hasUnmetPrerequisites` INTEGER NOT NULL, `isTooEarly` INTEGER NOT NULL, `isStaff` INTEGER NOT NULL, `auditAccessExpires` TEXT, `hasAccess` INTEGER, `errorCode` TEXT, `developerMessage` TEXT, `userMessage` TEXT, `additionalContextUserMessage` TEXT, `userFragment` TEXT, `certificateURL` TEXT, `created` TEXT, `mode` TEXT, `isActive` INTEGER NOT NULL, `upgradeDeadline` TEXT, `name` TEXT NOT NULL, `number` TEXT NOT NULL, `org` TEXT NOT NULL, `startDisplay` TEXT NOT NULL, `startType` TEXT NOT NULL, `isSelfPaced` INTEGER NOT NULL, `courseAbout` TEXT NOT NULL, `bannerImage` TEXT, `courseImage` TEXT, `courseVideo` TEXT, `image` TEXT, `facebook` TEXT NOT NULL, `twitter` TEXT NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "courseUpdates", + "columnName": "courseUpdates", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "courseHandouts", + "columnName": "courseHandouts", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "discussionUrl", + "columnName": "discussionUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "courseAccessDetails.hasUnmetPrerequisites", + "columnName": "hasUnmetPrerequisites", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "courseAccessDetails.isTooEarly", + "columnName": "isTooEarly", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "courseAccessDetails.isStaff", + "columnName": "isStaff", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "courseAccessDetails.auditAccessExpires", + "columnName": "auditAccessExpires", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "courseAccessDetails.coursewareAccess.hasAccess", + "columnName": "hasAccess", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "courseAccessDetails.coursewareAccess.errorCode", + "columnName": "errorCode", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "courseAccessDetails.coursewareAccess.developerMessage", + "columnName": "developerMessage", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "courseAccessDetails.coursewareAccess.userMessage", + "columnName": "userMessage", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "courseAccessDetails.coursewareAccess.additionalContextUserMessage", + "columnName": "additionalContextUserMessage", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "courseAccessDetails.coursewareAccess.userFragment", + "columnName": "userFragment", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "certificate.certificateURL", + "columnName": "certificateURL", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "enrollmentDetails.created", + "columnName": "created", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "enrollmentDetails.mode", + "columnName": "mode", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "enrollmentDetails.isActive", + "columnName": "isActive", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "enrollmentDetails.upgradeDeadline", + "columnName": "upgradeDeadline", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "courseInfoOverview.name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "courseInfoOverview.number", + "columnName": "number", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "courseInfoOverview.org", + "columnName": "org", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "courseInfoOverview.startDisplay", + "columnName": "startDisplay", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "courseInfoOverview.startType", + "columnName": "startType", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "courseInfoOverview.isSelfPaced", + "columnName": "isSelfPaced", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "courseInfoOverview.courseAbout", + "columnName": "courseAbout", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "courseInfoOverview.media.bannerImage", + "columnName": "bannerImage", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "courseInfoOverview.media.courseImage", + "columnName": "courseImage", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "courseInfoOverview.media.courseVideo", + "columnName": "courseVideo", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "courseInfoOverview.media.image", + "columnName": "image", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "courseInfoOverview.courseSharingUtmParameters.facebook", + "columnName": "facebook", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "courseInfoOverview.courseSharingUtmParameters.twitter", + "columnName": "twitter", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + } + ], + "views": [], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'ed545aec6739ec7692c4bb72179331c4')" + ] + } +} \ No newline at end of file diff --git a/app/src/main/java/org/openedx/app/room/AppDatabase.kt b/app/src/main/java/org/openedx/app/room/AppDatabase.kt index 6aa46ed1f..eec5b1811 100644 --- a/app/src/main/java/org/openedx/app/room/AppDatabase.kt +++ b/app/src/main/java/org/openedx/app/room/AppDatabase.kt @@ -1,10 +1,12 @@ package org.openedx.app.room +import androidx.room.AutoMigration import androidx.room.Database import androidx.room.RoomDatabase import androidx.room.TypeConverters import org.openedx.core.data.model.room.CourseCalendarEventEntity import org.openedx.core.data.model.room.CourseCalendarStateEntity +import org.openedx.core.data.model.room.CourseEnrollmentDetailsEntity import org.openedx.core.data.model.room.CourseStructureEntity import org.openedx.core.data.model.room.OfflineXBlockProgress import org.openedx.core.data.model.room.discovery.EnrolledCourseEntity @@ -18,7 +20,7 @@ import org.openedx.discovery.data.converter.DiscoveryConverter import org.openedx.discovery.data.model.room.CourseEntity import org.openedx.discovery.data.storage.DiscoveryDao -const val DATABASE_VERSION = 1 +const val DATABASE_VERSION = 2 const val DATABASE_NAME = "OpenEdX_db" @Database( @@ -29,10 +31,13 @@ const val DATABASE_NAME = "OpenEdX_db" DownloadModelEntity::class, OfflineXBlockProgress::class, CourseCalendarEventEntity::class, - CourseCalendarStateEntity::class + CourseCalendarStateEntity::class, + CourseEnrollmentDetailsEntity::class ], - version = DATABASE_VERSION, - exportSchema = false + autoMigrations = [ + AutoMigration(1, DATABASE_VERSION) + ], + version = DATABASE_VERSION ) @TypeConverters(DiscoveryConverter::class, CourseConverter::class) abstract class AppDatabase : RoomDatabase() { diff --git a/app/src/main/java/org/openedx/app/room/DatabaseManager.kt b/app/src/main/java/org/openedx/app/room/DatabaseManager.kt index 5d5415854..bcc123763 100644 --- a/app/src/main/java/org/openedx/app/room/DatabaseManager.kt +++ b/app/src/main/java/org/openedx/app/room/DatabaseManager.kt @@ -18,6 +18,7 @@ class DatabaseManager( override fun clearTables() { CoroutineScope(Dispatchers.IO).launch { courseDao.clearCachedData() + courseDao.clearEnrollmentCachedData() dashboardDao.clearCachedData() downloadDao.clearOfflineProgress() discoveryDao.clearCachedData() diff --git a/core/src/main/java/org/openedx/core/data/model/room/CourseEnrollmentDetailsEntity.kt b/core/src/main/java/org/openedx/core/data/model/room/CourseEnrollmentDetailsEntity.kt new file mode 100644 index 000000000..cc5d55278 --- /dev/null +++ b/core/src/main/java/org/openedx/core/data/model/room/CourseEnrollmentDetailsEntity.kt @@ -0,0 +1,84 @@ +package org.openedx.core.data.model.room + +import androidx.room.ColumnInfo +import androidx.room.Embedded +import androidx.room.Entity +import androidx.room.PrimaryKey +import org.openedx.core.data.model.room.discovery.CertificateDb +import org.openedx.core.data.model.room.discovery.CourseAccessDetailsDb +import org.openedx.core.data.model.room.discovery.CourseSharingUtmParametersDb +import org.openedx.core.data.model.room.discovery.EnrollmentDetailsDB +import org.openedx.core.domain.model.CourseEnrollmentDetails +import org.openedx.core.domain.model.CourseInfoOverview +import java.util.Date + +@Entity(tableName = "course_enrollment_details_table") +data class CourseEnrollmentDetailsEntity( + @PrimaryKey + @ColumnInfo("id") + val id: String, + @ColumnInfo("courseUpdates") + val courseUpdates: String, + @ColumnInfo("courseHandouts") + val courseHandouts: String, + @ColumnInfo("discussionUrl") + val discussionUrl: String, + @Embedded + val courseAccessDetails: CourseAccessDetailsDb, + @Embedded + val certificate: CertificateDb?, + @Embedded + val enrollmentDetails: EnrollmentDetailsDB, + @Embedded + val courseInfoOverview: CourseInfoOverviewDb +) { + fun mapToDomain() = CourseEnrollmentDetails( + id = id, + courseUpdates = courseUpdates, + courseHandouts = courseHandouts, + discussionUrl = discussionUrl, + courseAccessDetails = courseAccessDetails.mapToDomain(), + certificate = certificate?.mapToDomain(), + enrollmentDetails = enrollmentDetails.mapToDomain(), + courseInfoOverview = courseInfoOverview.mapToDomain() + ) +} + +data class CourseInfoOverviewDb( + @ColumnInfo("name") + val name: String, + @ColumnInfo("number") + val number: String, + @ColumnInfo("org") + val org: String, + @Embedded + val start: Date?, + @ColumnInfo("startDisplay") + val startDisplay: String, + @ColumnInfo("startType") + val startType: String, + @Embedded + val end: Date?, + @ColumnInfo("isSelfPaced") + val isSelfPaced: Boolean, + @Embedded + var media: MediaDb?, + @Embedded + val courseSharingUtmParameters: CourseSharingUtmParametersDb, + @ColumnInfo("courseAbout") + val courseAbout: String, +) { + fun mapToDomain() = CourseInfoOverview( + name = name, + number = number, + org = org, + start = start, + startDisplay = startDisplay, + startType = startType, + end = end, + isSelfPaced = isSelfPaced, + media = media?.mapToDomain(), + courseSharingUtmParameters = courseSharingUtmParameters.mapToDomain(), + courseAbout = courseAbout + ) +} diff --git a/core/src/main/java/org/openedx/core/domain/model/Certificate.kt b/core/src/main/java/org/openedx/core/domain/model/Certificate.kt index 054b75511..62fb51b50 100644 --- a/core/src/main/java/org/openedx/core/domain/model/Certificate.kt +++ b/core/src/main/java/org/openedx/core/domain/model/Certificate.kt @@ -2,10 +2,13 @@ package org.openedx.core.domain.model import android.os.Parcelable import kotlinx.parcelize.Parcelize +import org.openedx.core.data.model.room.discovery.CertificateDb @Parcelize data class Certificate( val certificateURL: String? ) : Parcelable { fun isCertificateEarned() = certificateURL?.isNotEmpty() == true + + fun mapToRoomEntity() = CertificateDb(certificateURL) } diff --git a/core/src/main/java/org/openedx/core/domain/model/CourseAccessDetails.kt b/core/src/main/java/org/openedx/core/domain/model/CourseAccessDetails.kt index fac674e66..2c95865e9 100644 --- a/core/src/main/java/org/openedx/core/domain/model/CourseAccessDetails.kt +++ b/core/src/main/java/org/openedx/core/domain/model/CourseAccessDetails.kt @@ -1,7 +1,9 @@ package org.openedx.core.domain.model import android.os.Parcelable +import com.google.gson.internal.bind.util.ISO8601Utils import kotlinx.parcelize.Parcelize +import org.openedx.core.data.model.room.discovery.CourseAccessDetailsDb import java.util.Date @Parcelize @@ -11,4 +13,14 @@ data class CourseAccessDetails( val isStaff: Boolean, val auditAccessExpires: Date?, val coursewareAccess: CoursewareAccess?, -) : Parcelable +) : Parcelable { + + fun mapToRoomEntity(): CourseAccessDetailsDb = + CourseAccessDetailsDb( + hasUnmetPrerequisites = hasUnmetPrerequisites, + isTooEarly = isTooEarly, + isStaff = isStaff, + auditAccessExpires = auditAccessExpires?.let { ISO8601Utils.format(it) }, + coursewareAccess = coursewareAccess?.mapToEntity() + ) +} diff --git a/core/src/main/java/org/openedx/core/domain/model/CourseEnrollmentDetails.kt b/core/src/main/java/org/openedx/core/domain/model/CourseEnrollmentDetails.kt index 5c61fee60..ec961dfcd 100644 --- a/core/src/main/java/org/openedx/core/domain/model/CourseEnrollmentDetails.kt +++ b/core/src/main/java/org/openedx/core/domain/model/CourseEnrollmentDetails.kt @@ -2,6 +2,7 @@ package org.openedx.core.domain.model import android.os.Parcelable import kotlinx.parcelize.Parcelize +import org.openedx.core.data.model.room.CourseEnrollmentDetailsEntity import org.openedx.core.extension.isNotNull import java.util.Date @@ -23,6 +24,17 @@ data class CourseEnrollmentDetails( val isAuditAccessExpired: Boolean get() = courseAccessDetails.auditAccessExpires.isNotNull() && Date().after(courseAccessDetails.auditAccessExpires) + + fun mapToEntity() = CourseEnrollmentDetailsEntity( + id = id, + courseUpdates = courseUpdates, + courseHandouts = courseHandouts, + discussionUrl = discussionUrl, + courseAccessDetails = courseAccessDetails.mapToRoomEntity(), + certificate = certificate?.mapToRoomEntity(), + enrollmentDetails = enrollmentDetails.mapToEntity(), + courseInfoOverview = courseInfoOverview.mapToEntity() + ) } enum class CourseAccessError { diff --git a/core/src/main/java/org/openedx/core/domain/model/CourseInfoOverview.kt b/core/src/main/java/org/openedx/core/domain/model/CourseInfoOverview.kt index 4d02f10b9..6895522f5 100644 --- a/core/src/main/java/org/openedx/core/domain/model/CourseInfoOverview.kt +++ b/core/src/main/java/org/openedx/core/domain/model/CourseInfoOverview.kt @@ -2,6 +2,7 @@ package org.openedx.core.domain.model import android.os.Parcelable import kotlinx.parcelize.Parcelize +import org.openedx.core.data.model.room.CourseInfoOverviewDb import java.util.Date @Parcelize @@ -10,7 +11,7 @@ data class CourseInfoOverview( val number: String, val org: String, val start: Date?, - val startDisplay: String, + val startDisplay: String?, val startType: String, val end: Date?, val isSelfPaced: Boolean, @@ -20,4 +21,18 @@ data class CourseInfoOverview( ) : Parcelable { val isStarted: Boolean get() = start?.before(Date()) ?: false + + fun mapToEntity() = CourseInfoOverviewDb( + name = name, + number = number, + org = org, + start = start, + startDisplay = startDisplay ?: "", + startType = startType, + end = end, + isSelfPaced = isSelfPaced, + media = media?.mapToEntity(), + courseSharingUtmParameters = courseSharingUtmParameters.mapToEntity(), + courseAbout = courseAbout + ) } diff --git a/core/src/main/java/org/openedx/core/domain/model/CourseSharingUtmParameters.kt b/core/src/main/java/org/openedx/core/domain/model/CourseSharingUtmParameters.kt index 186ef85fd..1d27361a3 100644 --- a/core/src/main/java/org/openedx/core/domain/model/CourseSharingUtmParameters.kt +++ b/core/src/main/java/org/openedx/core/domain/model/CourseSharingUtmParameters.kt @@ -2,9 +2,16 @@ package org.openedx.core.domain.model import android.os.Parcelable import kotlinx.parcelize.Parcelize +import org.openedx.core.data.model.room.discovery.CourseSharingUtmParametersDb @Parcelize data class CourseSharingUtmParameters( val facebook: String, val twitter: String -) : Parcelable +) : Parcelable { + + fun mapToEntity() = CourseSharingUtmParametersDb( + facebook = facebook, + twitter = twitter + ) +} diff --git a/core/src/main/java/org/openedx/core/domain/model/CoursewareAccess.kt b/core/src/main/java/org/openedx/core/domain/model/CoursewareAccess.kt index 5dd48d94e..9f0fd60e6 100644 --- a/core/src/main/java/org/openedx/core/domain/model/CoursewareAccess.kt +++ b/core/src/main/java/org/openedx/core/domain/model/CoursewareAccess.kt @@ -2,6 +2,7 @@ package org.openedx.core.domain.model import android.os.Parcelable import kotlinx.parcelize.Parcelize +import org.openedx.core.data.model.room.discovery.CoursewareAccessDb @Parcelize data class CoursewareAccess( @@ -11,4 +12,14 @@ data class CoursewareAccess( val userMessage: String, val additionalContextUserMessage: String, val userFragment: String -) : Parcelable +) : Parcelable { + + fun mapToEntity() = CoursewareAccessDb( + hasAccess = hasAccess, + errorCode = errorCode, + developerMessage = developerMessage, + userMessage = userMessage, + additionalContextUserMessage = additionalContextUserMessage, + userFragment = userFragment + ) +} diff --git a/core/src/main/java/org/openedx/core/domain/model/EnrollmentDetails.kt b/core/src/main/java/org/openedx/core/domain/model/EnrollmentDetails.kt index c9d39ec35..b880f3948 100644 --- a/core/src/main/java/org/openedx/core/domain/model/EnrollmentDetails.kt +++ b/core/src/main/java/org/openedx/core/domain/model/EnrollmentDetails.kt @@ -1,7 +1,9 @@ package org.openedx.core.domain.model import android.os.Parcelable +import com.google.gson.internal.bind.util.ISO8601Utils import kotlinx.parcelize.Parcelize +import org.openedx.core.data.model.room.discovery.EnrollmentDetailsDB import java.util.Date @Parcelize @@ -10,4 +12,12 @@ data class EnrollmentDetails( val mode: String?, val isActive: Boolean, val upgradeDeadline: Date?, -) : Parcelable +) : Parcelable { + + fun mapToEntity() = EnrollmentDetailsDB( + created = created?.let { ISO8601Utils.format(it) }, + mode = mode, + isActive = isActive, + upgradeDeadline = upgradeDeadline?.let { ISO8601Utils.format(it) } + ) +} diff --git a/core/src/main/java/org/openedx/core/domain/model/Media.kt b/core/src/main/java/org/openedx/core/domain/model/Media.kt index 51fa6dda5..572fcbdae 100644 --- a/core/src/main/java/org/openedx/core/domain/model/Media.kt +++ b/core/src/main/java/org/openedx/core/domain/model/Media.kt @@ -2,6 +2,11 @@ package org.openedx.core.domain.model import android.os.Parcelable import kotlinx.parcelize.Parcelize +import org.openedx.core.data.model.room.BannerImageDb +import org.openedx.core.data.model.room.CourseImageDb +import org.openedx.core.data.model.room.CourseVideoDb +import org.openedx.core.data.model.room.ImageDb +import org.openedx.core.data.model.room.MediaDb @Parcelize data class Media( @@ -9,28 +14,48 @@ data class Media( val courseImage: CourseImage? = null, val courseVideo: CourseVideo? = null, val image: Image? = null -) : Parcelable +) : Parcelable { + + fun mapToEntity() = MediaDb( + bannerImage = bannerImage?.mapToEntity(), + courseImage = courseImage?.mapToEntity(), + courseVideo = courseVideo?.mapToEntity(), + image = image?.mapToEntity() + ) +} @Parcelize data class Image( val large: String, val raw: String, val small: String -) : Parcelable +) : Parcelable { + + fun mapToEntity() = ImageDb(large, raw, small) +} @Parcelize data class CourseVideo( val uri: String -) : Parcelable +) : Parcelable { + + fun mapToEntity() = CourseVideoDb(uri) +} @Parcelize data class CourseImage( val uri: String, val name: String -) : Parcelable +) : Parcelable { + + fun mapToEntity() = CourseImageDb(uri, name) +} @Parcelize data class BannerImage( val uri: String, val uriAbsolute: String -) : Parcelable +) : Parcelable { + + fun mapToEntity() = BannerImageDb(uri, uriAbsolute) +} diff --git a/core/src/main/java/org/openedx/core/extension/CoroutineExt.kt b/core/src/main/java/org/openedx/core/extension/CoroutineExt.kt new file mode 100644 index 000000000..5a29ef9f5 --- /dev/null +++ b/core/src/main/java/org/openedx/core/extension/CoroutineExt.kt @@ -0,0 +1,14 @@ +package org.openedx.core.extension + +import kotlinx.coroutines.channels.ProducerScope +import kotlinx.coroutines.channels.awaitClose +import kotlinx.coroutines.flow.channelFlow +import kotlin.experimental.ExperimentalTypeInference + +@OptIn(ExperimentalTypeInference::class) +inline fun channelFlowWithAwait( + @BuilderInference crossinline block: suspend ProducerScope.() -> Unit +) = channelFlow { + block(this) + awaitClose() +} diff --git a/course/src/main/java/org/openedx/course/data/repository/CourseRepository.kt b/course/src/main/java/org/openedx/course/data/repository/CourseRepository.kt index d9034e4ef..bc508821d 100644 --- a/course/src/main/java/org/openedx/course/data/repository/CourseRepository.kt +++ b/course/src/main/java/org/openedx/course/data/repository/CourseRepository.kt @@ -1,5 +1,6 @@ package org.openedx.course.data.repository +import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.map import okhttp3.MultipartBody import org.openedx.core.ApiConstants @@ -9,15 +10,19 @@ import org.openedx.core.data.model.room.OfflineXBlockProgress import org.openedx.core.data.model.room.XBlockProgressData import org.openedx.core.data.storage.CorePreferences import org.openedx.core.domain.model.CourseComponentStatus +import org.openedx.core.domain.model.CourseDatesBannerInfo +import org.openedx.core.domain.model.CourseDatesResult import org.openedx.core.domain.model.CourseEnrollmentDetails import org.openedx.core.domain.model.CourseStructure import org.openedx.core.exception.NoCachedDataException +import org.openedx.core.extension.channelFlowWithAwait import org.openedx.core.module.db.DownloadDao import org.openedx.core.system.connection.NetworkConnection import org.openedx.course.data.storage.CourseDao import java.net.URLDecoder import java.nio.charset.StandardCharsets +@Suppress("TooManyFunctions") class CourseRepository( private val api: CourseApi, private val courseDao: CourseDao, @@ -25,7 +30,10 @@ class CourseRepository( private val preferencesManager: CorePreferences, private val networkConnection: NetworkConnection, ) { - private var courseStructure = mutableMapOf() + private val courseStructure = mutableMapOf() + + private val courseStatusMap = mutableMapOf() + private val courseDatesMap = mutableMapOf() suspend fun removeDownloadModel(id: String) { downloadDao.removeDownloadModel(id) @@ -37,6 +45,35 @@ class CourseRepository( suspend fun getAllDownloadModels() = downloadDao.readAllData().map { it.mapToDomain() } + suspend fun getCourseStructureFlow(courseId: String, forceRefresh: Boolean = true): Flow = + channelFlowWithAwait { + var hasCourseStructure = false + val cachedCourseStructure = courseStructure[courseId] ?: ( + courseDao.getCourseStructureById(courseId)?.mapToDomain() + ) + if (cachedCourseStructure != null) { + hasCourseStructure = true + trySend(cachedCourseStructure) + } + val fetchRemoteCourse = !hasCourseStructure || forceRefresh + if (networkConnection.isOnline() && fetchRemoteCourse) { + val response = api.getCourseStructure( + "stale-if-error=0", + "v4", + preferencesManager.user?.username, + courseId + ) + courseDao.insertCourseStructureEntity(response.mapToRoomEntity()) + val courseDomainModel = response.mapToDomain() + courseStructure[courseId] = courseDomainModel + trySend(courseDomainModel) + hasCourseStructure = true + } + if (!hasCourseStructure) { + throw NoCachedDataException() + } + } + suspend fun getCourseStructureFromCache(courseId: String): CourseStructure { val cachedCourseStructure = courseDao.getCourseStructureById(courseId) if (cachedCourseStructure != null) { @@ -70,10 +107,41 @@ class CourseRepository( return courseStructure[courseId]!! } + suspend fun getEnrollmentDetailsFlow(courseId: String): Flow = + channelFlowWithAwait { + getCourseEnrollmentDetailsFromCache(courseId)?.let { + trySend(it) + } + val details = getEnrollmentDetails(courseId) + courseDao.insertCourseEnrollmentDetailsEntity(details.mapToEntity()) + trySend(details) + } + + private suspend fun getCourseEnrollmentDetailsFromCache(courseId: String): CourseEnrollmentDetails? { + return courseDao.getCourseEnrollmentDetailsById(id = courseId) + ?.mapToDomain() + } + suspend fun getEnrollmentDetails(courseId: String): CourseEnrollmentDetails { return api.getEnrollmentDetails(courseId = courseId).mapToDomain() } + suspend fun getCourseStatusFlow(courseId: String): Flow = + channelFlowWithAwait { + val localStatus = courseStatusMap[courseId] + localStatus?.let { trySend(it) } + + if (networkConnection.isOnline()) { + val username = preferencesManager.user?.username ?: "" + val status = api.getCourseStatus(username, courseId).mapToDomain() + courseStatusMap[courseId] = status + trySend(status) + } else { + val status = localStatus ?: CourseComponentStatus("") + trySend(status) + } + } + suspend fun getCourseStatus(courseId: String): CourseComponentStatus { val username = preferencesManager.user?.username ?: "" return api.getCourseStatus(username, courseId).mapToDomain() @@ -89,6 +157,30 @@ class CourseRepository( return api.markBlocksCompletion(blocksCompletionBody) } + suspend fun getCourseDatesFlow(courseId: String): Flow = + channelFlowWithAwait { + val localDates = courseDatesMap[courseId] + localDates?.let { trySend(it) } + + if (networkConnection.isOnline()) { + val datesResult = api.getCourseDates(courseId).getCourseDatesResult() + courseDatesMap[courseId] = datesResult + trySend(datesResult) + } else { + val datesResult = localDates ?: CourseDatesResult( + datesSection = linkedMapOf(), + courseBanner = CourseDatesBannerInfo( + missedDeadlines = false, + missedGatedContent = false, + verifiedUpgradeLink = "", + contentTypeGatingEnabled = false, + hasEnded = false + ) + ) + trySend(datesResult) + } + } + suspend fun getCourseDates(courseId: String) = api.getCourseDates(courseId).getCourseDatesResult() diff --git a/course/src/main/java/org/openedx/course/data/storage/CourseDao.kt b/course/src/main/java/org/openedx/course/data/storage/CourseDao.kt index 63bd1c4d9..8c2d94f03 100644 --- a/course/src/main/java/org/openedx/course/data/storage/CourseDao.kt +++ b/course/src/main/java/org/openedx/course/data/storage/CourseDao.kt @@ -4,6 +4,7 @@ import androidx.room.Dao import androidx.room.Insert import androidx.room.OnConflictStrategy import androidx.room.Query +import org.openedx.core.data.model.room.CourseEnrollmentDetailsEntity import org.openedx.core.data.model.room.CourseStructureEntity @Dao @@ -17,4 +18,13 @@ interface CourseDao { @Query("DELETE FROM course_structure_table") suspend fun clearCachedData() + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun insertCourseEnrollmentDetailsEntity(vararg courseEnrollmentDetailsEntity: CourseEnrollmentDetailsEntity) + + @Query("SELECT * FROM course_enrollment_details_table WHERE id=:id") + suspend fun getCourseEnrollmentDetailsById(id: String): CourseEnrollmentDetailsEntity? + + @Query("DELETE FROM course_enrollment_details_table") + suspend fun clearEnrollmentCachedData() } diff --git a/course/src/main/java/org/openedx/course/domain/interactor/CourseInteractor.kt b/course/src/main/java/org/openedx/course/domain/interactor/CourseInteractor.kt index fdbcdd204..4678c9115 100644 --- a/course/src/main/java/org/openedx/course/domain/interactor/CourseInteractor.kt +++ b/course/src/main/java/org/openedx/course/domain/interactor/CourseInteractor.kt @@ -1,15 +1,24 @@ package org.openedx.course.domain.interactor +import kotlinx.coroutines.flow.Flow import org.openedx.core.BlockType import org.openedx.core.domain.model.Block import org.openedx.core.domain.model.CourseEnrollmentDetails import org.openedx.core.domain.model.CourseStructure import org.openedx.course.data.repository.CourseRepository +@Suppress("TooManyFunctions") class CourseInteractor( private val repository: CourseRepository ) { + suspend fun getCourseStructureFlow( + courseId: String, + forceRefresh: Boolean = true + ): Flow { + return repository.getCourseStructureFlow(courseId, forceRefresh) + } + suspend fun getCourseStructure( courseId: String, isNeedRefresh: Boolean = false @@ -21,6 +30,10 @@ class CourseInteractor( return repository.getCourseStructureFromCache(courseId) } + suspend fun getEnrollmentDetailsFlow(courseId: String): Flow { + return repository.getEnrollmentDetailsFlow(courseId) + } + suspend fun getEnrollmentDetails(courseId: String): CourseEnrollmentDetails { return repository.getEnrollmentDetails(courseId = courseId) } @@ -68,8 +81,12 @@ class CourseInteractor( } } + suspend fun getCourseStatusFlow(courseId: String) = repository.getCourseStatusFlow(courseId) + suspend fun getCourseStatus(courseId: String) = repository.getCourseStatus(courseId) + suspend fun getCourseDatesFlow(courseId: String) = repository.getCourseDatesFlow(courseId) + suspend fun getCourseDates(courseId: String) = repository.getCourseDates(courseId) suspend fun resetCourseDates(courseId: String) = repository.resetCourseDates(courseId) 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 ac1cb591e..0e7288423 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 @@ -6,7 +6,6 @@ import android.os.Build import androidx.lifecycle.LiveData import androidx.lifecycle.MutableLiveData import androidx.lifecycle.viewModelScope -import kotlinx.coroutines.async import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow @@ -14,9 +13,10 @@ import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.catch +import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch -import kotlinx.coroutines.supervisorScope import org.openedx.core.config.Config import org.openedx.core.data.storage.CorePreferences import org.openedx.core.domain.model.CourseAccessError @@ -170,47 +170,27 @@ class CourseContainerViewModel( _showProgress.value = true viewModelScope.launch { - try { - val (courseStructure, courseEnrollmentDetails) = fetchCourseData(courseId) - _showProgress.value = false - when { - courseEnrollmentDetails != null -> { - handleCourseEnrollment(courseEnrollmentDetails) - } - - courseStructure != null -> { - handleCourseStructureOnly(courseStructure) - } - - else -> { - _courseAccessStatus.value = CourseAccessError.UNKNOWN - } + val courseStructureFlow = interactor.getCourseStructureFlow(courseId) + .catch { e -> + handleFetchError(e) + emit(null) } - } catch (e: Exception) { - e.printStackTrace() + val courseDetailsFlow = interactor.getEnrollmentDetailsFlow(courseId) + .catch { emit(null) } + courseStructureFlow.combine(courseDetailsFlow) { courseStructure, courseEnrollmentDetails -> + courseStructure to courseEnrollmentDetails + }.catch { e -> handleFetchError(e) - _showProgress.value = false + }.collect { (courseStructure, courseEnrollmentDetails) -> + when { + courseEnrollmentDetails != null -> handleCourseEnrollment(courseEnrollmentDetails) + courseStructure != null -> handleCourseStructureOnly(courseStructure) + else -> _courseAccessStatus.value = CourseAccessError.UNKNOWN + } } } } - private suspend fun fetchCourseData( - courseId: String - ): Pair = supervisorScope { - val deferredCourse = async { - runCatching { - interactor.getCourseStructure(courseId, isNeedRefresh = true) - }.getOrNull() - } - val deferredEnrollment = async { - runCatching { - interactor.getEnrollmentDetails(courseId) - }.getOrNull() - } - - Pair(deferredCourse.await(), deferredEnrollment.await()) - } - /** * Handles the scenario where [CourseEnrollmentDetails] is successfully fetched. */ @@ -262,15 +242,17 @@ class CourseContainerViewModel( _dataReady.value = true } - private fun handleFetchError(e: Exception) { + private fun handleFetchError(e: Throwable) { + e.printStackTrace() if (isNetworkRelatedError(e)) { _errorMessage.value = resourceManager.getString(CoreR.string.core_error_no_connection) } else { _courseAccessStatus.value = CourseAccessError.UNKNOWN } + _showProgress.value = false } - private fun isNetworkRelatedError(e: Exception): Boolean { + private fun isNetworkRelatedError(e: Throwable): Boolean { return e.isInternetError() || e is NoCachedDataException } 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 4b373b05f..916213026 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 @@ -8,6 +8,8 @@ import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.catch +import kotlinx.coroutines.flow.combine import kotlinx.coroutines.launch import org.openedx.core.BlockType import org.openedx.core.R @@ -17,7 +19,6 @@ import org.openedx.core.domain.model.Block import org.openedx.core.domain.model.CourseComponentStatus import org.openedx.core.domain.model.CourseDateBlock import org.openedx.core.domain.model.CourseDatesBannerInfo -import org.openedx.core.domain.model.CourseDatesResult import org.openedx.core.domain.model.CourseStructure import org.openedx.core.extension.getSequentialBlocks import org.openedx.core.extension.getVerticalBlocks @@ -183,48 +184,31 @@ class CourseOutlineViewModel( private fun getCourseDataInternal() { viewModelScope.launch { - try { - val courseStructure = interactor.getCourseStructure(courseId) + val courseStructureFlow = interactor.getCourseStructureFlow(courseId, false) + .catch { emit(null) } + val courseStatusFlow = interactor.getCourseStatusFlow(courseId) + val courseDatesFlow = interactor.getCourseDatesFlow(courseId) + combine( + courseStructureFlow, + courseStatusFlow, + courseDatesFlow + ) { courseStructure, courseStatus, courseDatesResult -> + Triple(courseStructure, courseStatus, courseDatesResult) + }.catch { e -> + handleCourseDataError(e) + }.collect { (courseStructure, courseStatus, courseDates) -> + if (courseStructure == null) return@collect val blocks = courseStructure.blockData - val courseStatus = fetchCourseStatus() - val courseDatesResult = fetchCourseDates() - val datesBannerInfo = courseDatesResult.courseBanner + val datesBannerInfo = courseDates.courseBanner - checkIfCalendarOutOfDate(courseDatesResult.datesSection.values.flatten()) + checkIfCalendarOutOfDate(courseDates.datesSection.values.flatten()) updateOutdatedOfflineXBlocks(courseStructure) initializeCourseData(blocks, courseStructure, courseStatus, datesBannerInfo) - } catch (e: Exception) { - handleCourseDataError(e) } } } - private suspend fun fetchCourseStatus(): CourseComponentStatus { - return if (networkConnection.isOnline()) { - interactor.getCourseStatus(courseId) - } else { - CourseComponentStatus("") - } - } - - private suspend fun fetchCourseDates(): CourseDatesResult { - return if (networkConnection.isOnline()) { - interactor.getCourseDates(courseId) - } else { - CourseDatesResult( - datesSection = linkedMapOf(), - courseBanner = CourseDatesBannerInfo( - missedDeadlines = false, - missedGatedContent = false, - verifiedUpgradeLink = "", - contentTypeGatingEnabled = false, - hasEnded = false - ) - ) - } - } - private suspend fun initializeCourseData( blocks: List, courseStructure: CourseStructure, @@ -253,10 +237,10 @@ class CourseOutlineViewModel( ) } - private suspend fun handleCourseDataError(e: Exception) { + private suspend fun handleCourseDataError(e: Throwable?) { _uiState.value = CourseOutlineUIState.Error val errorMessage = when { - e.isInternetError() -> R.string.core_error_no_connection + e?.isInternetError() == true -> R.string.core_error_no_connection else -> R.string.core_error_unknown_error } _uiMessage.emit(UIMessage.SnackBarMessage(resourceManager.getString(errorMessage))) @@ -279,8 +263,10 @@ class CourseOutlineViewModel( block.descendants.forEach { descendantId -> val sequentialBlock = blocks.find { it.id == descendantId } ?: return@forEach addSequentialBlockToSubSections(block, sequentialBlock) - courseSubSectionUnit[sequentialBlock.id] = sequentialBlock.getFirstDescendantBlock(blocks) - subSectionsDownloadsCount[sequentialBlock.id] = sequentialBlock.getDownloadsCount(blocks) + courseSubSectionUnit[sequentialBlock.id] = + sequentialBlock.getFirstDescendantBlock(blocks) + subSectionsDownloadsCount[sequentialBlock.id] = + sequentialBlock.getDownloadsCount(blocks) addDownloadableChildrenForSequentialBlock(sequentialBlock) } } @@ -434,10 +420,12 @@ class CourseOutlineViewModel( viewModelScope.launch { val courseData = _uiState.value as? CourseOutlineUIState.CourseData ?: return@launch - val subSectionsBlocks = courseData.courseSubSections.values.flatten().filter { it.id in blocksIds } + val subSectionsBlocks = + courseData.courseSubSections.values.flatten().filter { it.id in blocksIds } val blocks = subSectionsBlocks.flatMap { subSectionsBlock -> - val verticalBlocks = allBlocks.values.filter { it.id in subSectionsBlock.descendants } + val verticalBlocks = + allBlocks.values.filter { it.id in subSectionsBlock.descendants } allBlocks.values.filter { it.id in verticalBlocks.flatMap { it.descendants } } } @@ -446,9 +434,12 @@ class CourseOutlineViewModel( val isAllBlocksDownloaded = downloadableBlocks.all { isBlockDownloaded(it.id) } val notDownloadedSubSectionBlocks = subSectionsBlocks.mapNotNull { subSectionsBlock -> - val verticalBlocks = allBlocks.values.filter { it.id in subSectionsBlock.descendants } + val verticalBlocks = + allBlocks.values.filter { it.id in subSectionsBlock.descendants } val notDownloadedBlocks = allBlocks.values.filter { - it.id in verticalBlocks.flatMap { it.descendants } && it.isDownloadable && !isBlockDownloaded(it.id) + it.id in verticalBlocks.flatMap { it.descendants } && it.isDownloadable && !isBlockDownloaded( + it.id + ) } if (notDownloadedBlocks.isNotEmpty()) { subSectionsBlock @@ -462,7 +453,8 @@ class CourseOutlineViewModel( } if (downloadingBlocks.isNotEmpty()) { - val downloadableChildren = downloadingBlocks.flatMap { getDownloadableChildren(it).orEmpty() } + val downloadableChildren = + downloadingBlocks.flatMap { getDownloadableChildren(it).orEmpty() } if (config.getCourseUIConfig().isCourseDownloadQueueEnabled) { courseRouter.navigateToDownloadQueue(fragmentManager, downloadableChildren) } else { 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 531bef58f..f9b17792c 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 @@ -11,6 +11,8 @@ import io.mockk.verify import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.emptyFlow +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.test.StandardTestDispatcher import kotlinx.coroutines.test.advanceUntilIdle import kotlinx.coroutines.test.resetMain @@ -215,6 +217,7 @@ class CourseContainerViewModelTest { Dispatchers.resetMain() } + @Suppress("TooGenericExceptionThrown") @Test fun `getCourseEnrollmentDetails unknown exception`() = runTest { val viewModel = CourseContainerViewModel( @@ -233,8 +236,12 @@ class CourseContainerViewModelTest { courseRouter ) every { networkConnection.isOnline() } returns true - coEvery { interactor.getCourseStructure(any(), any()) } throws Exception() - coEvery { interactor.getEnrollmentDetails(any()) } throws Exception() + coEvery { + interactor.getCourseStructureFlow(any(), any()) + } returns flowOf(null) + coEvery { + interactor.getEnrollmentDetailsFlow(any()) + } returns flow { throw Exception() } every { analytics.logScreenEvent( CourseAnalyticsEvent.DASHBOARD.eventName, @@ -250,7 +257,7 @@ class CourseContainerViewModelTest { viewModel.fetchCourseDetails() advanceUntilIdle() - coVerify(exactly = 1) { interactor.getEnrollmentDetails(any()) } + coVerify(exactly = 1) { interactor.getEnrollmentDetailsFlow(any()) } verify(exactly = 1) { analytics.logScreenEvent( CourseAnalyticsEvent.DASHBOARD.eventName, @@ -285,8 +292,8 @@ class CourseContainerViewModelTest { courseRouter ) every { networkConnection.isOnline() } returns true - coEvery { interactor.getCourseStructure(any(), any()) } returns courseStructure - coEvery { interactor.getEnrollmentDetails(any()) } returns enrollmentDetails + coEvery { interactor.getCourseStructureFlow(any(), any()) } returns flowOf(courseStructure) + coEvery { interactor.getEnrollmentDetailsFlow(any()) } returns flowOf(enrollmentDetails) every { analytics.logScreenEvent( CourseAnalyticsEvent.DASHBOARD.eventName, @@ -302,7 +309,7 @@ class CourseContainerViewModelTest { viewModel.fetchCourseDetails() advanceUntilIdle() - coVerify(exactly = 1) { interactor.getEnrollmentDetails(any()) } + coVerify(exactly = 1) { interactor.getEnrollmentDetailsFlow(any()) } verify(exactly = 1) { analytics.logScreenEvent( CourseAnalyticsEvent.DASHBOARD.eventName, @@ -338,7 +345,8 @@ class CourseContainerViewModelTest { courseRouter ) every { networkConnection.isOnline() } returns false - coEvery { interactor.getEnrollmentDetails(any()) } returns enrollmentDetails + coEvery { interactor.getCourseStructureFlow(any(), any()) } returns flowOf(courseStructure) + coEvery { interactor.getEnrollmentDetailsFlow(any()) } returns flowOf(enrollmentDetails) every { analytics.logScreenEvent( CourseAnalyticsEvent.DASHBOARD.eventName, 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 663409188..c95916668 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 @@ -15,6 +15,7 @@ import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.async import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.test.UnconfinedTestDispatcher import kotlinx.coroutines.test.advanceUntilIdle import kotlinx.coroutines.test.resetMain @@ -239,6 +240,7 @@ class CourseOutlineViewModelTest { every { preferencesManager.isRelativeDatesEnabled } returns true coEvery { interactor.getCourseDates(any()) } returns mockedCourseDatesResult + coEvery { interactor.getCourseDatesFlow(any()) } returns flowOf(mockedCourseDatesResult) } @After @@ -247,51 +249,65 @@ class CourseOutlineViewModelTest { } @Test - fun `getCourseDataInternal no internet connection exception`() = runTest(UnconfinedTestDispatcher()) { - coEvery { interactor.getCourseStructure(any()) } returns courseStructure - every { networkConnection.isOnline() } returns true - every { downloadDao.getAllDataFlow() } returns flow { emit(emptyList()) } - every { downloadDialogManager.showPopup(any(), any(), any(), any(), any(), any(), any()) } returns Unit - coEvery { interactor.getCourseStatus(any()) } throws UnknownHostException() - - val viewModel = CourseOutlineViewModel( - "", - "", - config, - interactor, - resourceManager, - notifier, - networkConnection, - preferencesManager, - analytics, - downloadDialogManager, - fileUtil, - courseRouter, - coreAnalytics, - downloadDao, - workerController, - downloadHelper, - ) + fun `getCourseDataInternal no internet connection exception`() = + runTest(UnconfinedTestDispatcher()) { + coEvery { interactor.getCourseStructureFlow(any(), any()) } returns flowOf( + courseStructure + ) + every { networkConnection.isOnline() } returns true + every { downloadDao.getAllDataFlow() } returns flow { emit(emptyList()) } + every { + downloadDialogManager.showPopup( + any(), + any(), + any(), + any(), + any(), + any(), + any() + ) + } returns Unit + coEvery { interactor.getCourseStatusFlow(any()) } returns flow { throw UnknownHostException() } + + val viewModel = CourseOutlineViewModel( + "", + "", + config, + interactor, + resourceManager, + notifier, + networkConnection, + preferencesManager, + analytics, + downloadDialogManager, + fileUtil, + courseRouter, + coreAnalytics, + downloadDao, + workerController, + downloadHelper, + ) - val message = async { - viewModel.uiMessage.first() as? UIMessage.SnackBarMessage - } - viewModel.getCourseData() - advanceUntilIdle() + val message = async { + viewModel.uiMessage.first() as? UIMessage.SnackBarMessage + } + viewModel.getCourseData() + advanceUntilIdle() - coVerify(exactly = 2) { interactor.getCourseStructure(any()) } - coVerify(exactly = 2) { interactor.getCourseStatus(any()) } + coVerify(exactly = 2) { interactor.getCourseStructureFlow(any(), any()) } + coVerify(exactly = 2) { interactor.getCourseStatusFlow(any()) } - assertEquals(noInternet, message.await()?.message) - assert(viewModel.uiState.value is CourseOutlineUIState.Error) - } + assertEquals(noInternet, message.await()?.message) + assert(viewModel.uiState.value is CourseOutlineUIState.Error) + } + @Suppress("TooGenericExceptionThrown") @Test fun `getCourseDataInternal unknown exception`() = runTest(UnconfinedTestDispatcher()) { - coEvery { interactor.getCourseStructure(any()) } returns courseStructure + coEvery { interactor.getCourseStructureFlow(any(), any()) } returns flowOf(courseStructure) every { networkConnection.isOnline() } returns true every { downloadDao.getAllDataFlow() } returns flow { emit(emptyList()) } - coEvery { interactor.getCourseStatus(any()) } throws Exception() + coEvery { interactor.getCourseStatusFlow(any()) } returns flow { throw Exception() } val viewModel = CourseOutlineViewModel( "", "", @@ -317,167 +333,181 @@ class CourseOutlineViewModelTest { viewModel.getCourseData() advanceUntilIdle() - coVerify(exactly = 2) { interactor.getCourseStructure(any()) } - coVerify(exactly = 2) { interactor.getCourseStatus(any()) } + coVerify(exactly = 2) { interactor.getCourseStructureFlow(any(), any()) } + coVerify(exactly = 2) { interactor.getCourseStatusFlow(any()) } assertEquals(somethingWrong, message.await()?.message) assert(viewModel.uiState.value is CourseOutlineUIState.Error) } @Test - fun `getCourseDataInternal success with internet connection`() = runTest(UnconfinedTestDispatcher()) { - coEvery { interactor.getCourseStructure(any()) } returns courseStructure - every { networkConnection.isOnline() } returns true - coEvery { downloadDao.getAllDataFlow() } returns flow { - emit( - listOf( - DownloadModelEntity.createFrom( - downloadModel + fun `getCourseDataInternal success with internet connection`() = + runTest(UnconfinedTestDispatcher()) { + coEvery { interactor.getCourseStructureFlow(any(), any()) } returns flowOf( + courseStructure + ) + every { networkConnection.isOnline() } returns true + coEvery { downloadDao.getAllDataFlow() } returns flow { + emit( + listOf( + DownloadModelEntity.createFrom( + downloadModel + ) ) ) + } + coEvery { interactor.getCourseStatusFlow(any()) } returns flowOf(CourseComponentStatus("id")) + every { config.getCourseUIConfig().isCourseDropdownNavigationEnabled } returns false + + val viewModel = CourseOutlineViewModel( + "", + "", + config, + interactor, + resourceManager, + notifier, + networkConnection, + preferencesManager, + analytics, + downloadDialogManager, + fileUtil, + courseRouter, + coreAnalytics, + downloadDao, + workerController, + downloadHelper, ) - } - coEvery { interactor.getCourseStatus(any()) } returns CourseComponentStatus("id") - every { config.getCourseUIConfig().isCourseDropdownNavigationEnabled } returns false - val viewModel = CourseOutlineViewModel( - "", - "", - config, - interactor, - resourceManager, - notifier, - networkConnection, - preferencesManager, - analytics, - downloadDialogManager, - fileUtil, - courseRouter, - coreAnalytics, - downloadDao, - workerController, - downloadHelper, - ) - - val message = async { - withTimeoutOrNull(5000) { - viewModel.uiMessage.first() as? UIMessage.SnackBarMessage + val message = async { + withTimeoutOrNull(5000) { + viewModel.uiMessage.first() as? UIMessage.SnackBarMessage + } } - } - viewModel.getCourseData() - advanceUntilIdle() + viewModel.getCourseData() + advanceUntilIdle() - coVerify(exactly = 2) { interactor.getCourseStructure(any()) } - coVerify(exactly = 2) { interactor.getCourseStatus(any()) } + coVerify(exactly = 2) { interactor.getCourseStructureFlow(any(), any()) } + coVerify(exactly = 2) { interactor.getCourseStatusFlow(any()) } - assert(message.await() == null) - assert(viewModel.uiState.value is CourseOutlineUIState.CourseData) - } + assert(message.await() == null) + assert(viewModel.uiState.value is CourseOutlineUIState.CourseData) + } @Test - fun `getCourseDataInternal success without internet connection`() = runTest(UnconfinedTestDispatcher()) { - coEvery { interactor.getCourseStructure(any()) } returns courseStructure - every { networkConnection.isOnline() } returns false - coEvery { downloadDao.getAllDataFlow() } returns flow { - emit( - listOf( - DownloadModelEntity.createFrom( - downloadModel + fun `getCourseDataInternal success without internet connection`() = + runTest(UnconfinedTestDispatcher()) { + coEvery { interactor.getCourseStructureFlow(any(), any()) } returns flowOf( + courseStructure + ) + every { networkConnection.isOnline() } returns false + coEvery { downloadDao.getAllDataFlow() } returns flow { + emit( + listOf( + DownloadModelEntity.createFrom( + downloadModel + ) ) ) + } + coEvery { interactor.getCourseStatusFlow(any()) } returns flowOf(CourseComponentStatus("id")) + every { config.getCourseUIConfig().isCourseDropdownNavigationEnabled } returns false + + val viewModel = CourseOutlineViewModel( + "", + "", + config, + interactor, + resourceManager, + notifier, + networkConnection, + preferencesManager, + analytics, + downloadDialogManager, + fileUtil, + courseRouter, + coreAnalytics, + downloadDao, + workerController, + downloadHelper, ) - } - coEvery { interactor.getCourseStatus(any()) } returns CourseComponentStatus("id") - every { config.getCourseUIConfig().isCourseDropdownNavigationEnabled } returns false - val viewModel = CourseOutlineViewModel( - "", - "", - config, - interactor, - resourceManager, - notifier, - networkConnection, - preferencesManager, - analytics, - downloadDialogManager, - fileUtil, - courseRouter, - coreAnalytics, - downloadDao, - workerController, - downloadHelper, - ) - - val message = async { - withTimeoutOrNull(5000) { - viewModel.uiMessage.first() as? UIMessage.SnackBarMessage + val message = async { + withTimeoutOrNull(5000) { + viewModel.uiMessage.first() as? UIMessage.SnackBarMessage + } } - } - viewModel.getCourseData() - advanceUntilIdle() + viewModel.getCourseData() + advanceUntilIdle() - coVerify(exactly = 2) { interactor.getCourseStructure(any()) } - coVerify(exactly = 0) { interactor.getCourseStatus(any()) } + coVerify(exactly = 2) { interactor.getCourseStructureFlow(any(), any()) } + coVerify(exactly = 2) { interactor.getCourseStatusFlow(any()) } - assert(message.await() == null) - assert(viewModel.uiState.value is CourseOutlineUIState.CourseData) - } + assert(message.await() == null) + assert(viewModel.uiState.value is CourseOutlineUIState.CourseData) + } @Test - fun `updateCourseData success with internet connection`() = runTest(UnconfinedTestDispatcher()) { - coEvery { interactor.getCourseStructure(any()) } returns courseStructure - every { networkConnection.isOnline() } returns true - coEvery { downloadDao.getAllDataFlow() } returns flow { - emit( - listOf( - DownloadModelEntity.createFrom( - downloadModel + fun `updateCourseData success with internet connection`() = + runTest(UnconfinedTestDispatcher()) { + coEvery { interactor.getCourseStructureFlow(any(), any()) } returns flowOf( + courseStructure + ) + every { networkConnection.isOnline() } returns true + coEvery { downloadDao.getAllDataFlow() } returns flow { + emit( + listOf( + DownloadModelEntity.createFrom( + downloadModel + ) ) ) + } + coEvery { interactor.getCourseStatusFlow(any()) } returns flowOf(CourseComponentStatus("id")) + every { config.getCourseUIConfig().isCourseDropdownNavigationEnabled } returns false + + val viewModel = CourseOutlineViewModel( + "", + "", + config, + interactor, + resourceManager, + notifier, + networkConnection, + preferencesManager, + analytics, + downloadDialogManager, + fileUtil, + courseRouter, + coreAnalytics, + downloadDao, + workerController, + downloadHelper, ) - } - coEvery { interactor.getCourseStatus(any()) } returns CourseComponentStatus("id") - every { config.getCourseUIConfig().isCourseDropdownNavigationEnabled } returns false - val viewModel = CourseOutlineViewModel( - "", - "", - config, - interactor, - resourceManager, - notifier, - networkConnection, - preferencesManager, - analytics, - downloadDialogManager, - fileUtil, - courseRouter, - coreAnalytics, - downloadDao, - workerController, - downloadHelper, - ) - - val message = async { - withTimeoutOrNull(5000) { - viewModel.uiMessage.first() as? UIMessage.SnackBarMessage + val message = async { + withTimeoutOrNull(5000) { + viewModel.uiMessage.first() as? UIMessage.SnackBarMessage + } } - } - viewModel.getCourseData() - advanceUntilIdle() + viewModel.getCourseData() + advanceUntilIdle() - coVerify(exactly = 2) { interactor.getCourseStructure(any()) } - coVerify(exactly = 2) { interactor.getCourseStatus(any()) } + coVerify(exactly = 2) { interactor.getCourseStructureFlow(any(), any()) } + coVerify(exactly = 2) { interactor.getCourseStatusFlow(any()) } - assert(message.await() == null) - assert(viewModel.uiState.value is CourseOutlineUIState.CourseData) - } + assert(message.await() == null) + assert(viewModel.uiState.value is CourseOutlineUIState.CourseData) + } @Test fun `CourseStructureUpdated notifier test`() = runTest(UnconfinedTestDispatcher()) { coEvery { downloadDao.getAllDataFlow() } returns flow { emit(emptyList()) } + coEvery { interactor.getCourseStructureFlow(any(), any()) } returns flowOf(courseStructure) + coEvery { notifier.notifier } returns flow { emit(CourseStructureUpdated("")) } + every { networkConnection.isOnline() } returns true + coEvery { interactor.getCourseStatusFlow(any()) } returns flowOf(CourseComponentStatus("id")) + val viewModel = CourseOutlineViewModel( "", "", @@ -496,10 +526,6 @@ class CourseOutlineViewModelTest { workerController, downloadHelper, ) - coEvery { notifier.notifier } returns flow { emit(CourseStructureUpdated("")) } - coEvery { interactor.getCourseStructure(any()) } returns courseStructure - every { networkConnection.isOnline() } returns true - coEvery { interactor.getCourseStatus(any()) } returns CourseComponentStatus("id") val mockLifeCycleOwner: LifecycleOwner = mockk() val lifecycleRegistry = LifecycleRegistry(mockLifeCycleOwner) @@ -509,14 +535,15 @@ class CourseOutlineViewModelTest { viewModel.getCourseData() advanceUntilIdle() - coVerify(exactly = 2) { interactor.getCourseStructure(any()) } - coVerify(exactly = 1) { interactor.getCourseStatus(any()) } + coVerify(exactly = 3) { interactor.getCourseStructureFlow(any(), any()) } + coVerify(exactly = 3) { interactor.getCourseStatusFlow(any()) } } @Test fun `saveDownloadModels test`() = runTest(UnconfinedTestDispatcher()) { every { preferencesManager.videoSettings.wifiDownloadOnly } returns false coEvery { interactor.getCourseStructure(any()) } returns courseStructure + coEvery { interactor.getCourseStructureFlow(any(), any()) } returns flowOf(courseStructure) every { networkConnection.isWifiConnected() } returns true every { networkConnection.isOnline() } returns true every { @@ -527,6 +554,7 @@ class CourseOutlineViewModelTest { } returns Unit coEvery { workerController.saveModels(any()) } returns Unit coEvery { interactor.getCourseStatus(any()) } returns CourseComponentStatus("id") + coEvery { interactor.getCourseStatusFlow(any()) } returns flowOf(CourseComponentStatus("id")) coEvery { downloadDao.getAllDataFlow() } returns flow { emit(emptyList()) } every { config.getCourseUIConfig().isCourseDropdownNavigationEnabled } returns false @@ -566,43 +594,48 @@ class CourseOutlineViewModelTest { } @Test - fun `saveDownloadModels only wifi download, with connection`() = runTest(UnconfinedTestDispatcher()) { - coEvery { interactor.getCourseStructure(any()) } returns courseStructure - coEvery { interactor.getCourseStatus(any()) } returns CourseComponentStatus("id") - every { preferencesManager.videoSettings.wifiDownloadOnly } returns true - every { networkConnection.isWifiConnected() } returns true - every { networkConnection.isOnline() } returns true - coEvery { workerController.saveModels(any()) } returns Unit - coEvery { downloadDao.getAllDataFlow() } returns flow { emit(emptyList()) } - every { config.getCourseUIConfig().isCourseDropdownNavigationEnabled } returns false - every { coreAnalytics.logEvent(any(), any()) } returns Unit - - val viewModel = CourseOutlineViewModel( - "", - "", - config, - interactor, - resourceManager, - notifier, - networkConnection, - preferencesManager, - analytics, - downloadDialogManager, - fileUtil, - courseRouter, - coreAnalytics, - downloadDao, - workerController, - downloadHelper, - ) - val message = async { - withTimeoutOrNull(5000) { - viewModel.uiMessage.first() as? UIMessage.SnackBarMessage + fun `saveDownloadModels only wifi download, with connection`() = + runTest(UnconfinedTestDispatcher()) { + coEvery { interactor.getCourseStructure(any()) } returns courseStructure + coEvery { interactor.getCourseStructureFlow(any(), any()) } returns flowOf( + courseStructure + ) + coEvery { interactor.getCourseStatus(any()) } returns CourseComponentStatus("id") + coEvery { interactor.getCourseStatusFlow(any()) } returns flowOf(CourseComponentStatus("id")) + every { preferencesManager.videoSettings.wifiDownloadOnly } returns true + every { networkConnection.isWifiConnected() } returns true + every { networkConnection.isOnline() } returns true + coEvery { workerController.saveModels(any()) } returns Unit + coEvery { downloadDao.getAllDataFlow() } returns flow { emit(emptyList()) } + every { config.getCourseUIConfig().isCourseDropdownNavigationEnabled } returns false + every { coreAnalytics.logEvent(any(), any()) } returns Unit + + val viewModel = CourseOutlineViewModel( + "", + "", + config, + interactor, + resourceManager, + notifier, + networkConnection, + preferencesManager, + analytics, + downloadDialogManager, + fileUtil, + courseRouter, + coreAnalytics, + downloadDao, + workerController, + downloadHelper, + ) + val message = async { + withTimeoutOrNull(5000) { + viewModel.uiMessage.first() as? UIMessage.SnackBarMessage + } } - } - viewModel.saveDownloadModels("", "") - advanceUntilIdle() + viewModel.saveDownloadModels("", "") + advanceUntilIdle() - assert(message.await()?.message.isNullOrEmpty()) - } + assert(message.await()?.message.isNullOrEmpty()) + } } diff --git a/dashboard/src/main/java/org/openedx/courses/presentation/AllEnrolledCoursesViewModel.kt b/dashboard/src/main/java/org/openedx/courses/presentation/AllEnrolledCoursesViewModel.kt index c8363e24d..80c0d5fce 100644 --- a/dashboard/src/main/java/org/openedx/courses/presentation/AllEnrolledCoursesViewModel.kt +++ b/dashboard/src/main/java/org/openedx/courses/presentation/AllEnrolledCoursesViewModel.kt @@ -58,11 +58,24 @@ class AllEnrolledCoursesViewModel( init { collectDiscoveryNotifier() - getCourses(currentFilter.value) + loadInitialCourses() } - fun getCourses(courseStatusFilter: CourseStatusFilter? = null) { - _uiState.update { it.copy(showProgress = true) } + private fun loadInitialCourses() { + viewModelScope.launch { + _uiState.update { it.copy(showProgress = true) } + val cachedList = interactor.getEnrolledCoursesFromCache() + if (cachedList.isNotEmpty()) { + _uiState.update { it.copy(courses = cachedList.toList(), showProgress = false) } + } + getCourses(showLoadingProgress = false) + } + } + + fun getCourses(courseStatusFilter: CourseStatusFilter? = null, showLoadingProgress: Boolean = true) { + if (showLoadingProgress) { + _uiState.update { it.copy(showProgress = true) } + } coursesList.clear() internalLoadingCourses(courseStatusFilter ?: currentFilter.value) } diff --git a/dashboard/src/main/java/org/openedx/courses/presentation/DashboardGalleryViewModel.kt b/dashboard/src/main/java/org/openedx/courses/presentation/DashboardGalleryViewModel.kt index aacb85719..0ca8f4a6e 100644 --- a/dashboard/src/main/java/org/openedx/courses/presentation/DashboardGalleryViewModel.kt +++ b/dashboard/src/main/java/org/openedx/courses/presentation/DashboardGalleryViewModel.kt @@ -67,6 +67,20 @@ class DashboardGalleryViewModel( fun getCourses() { viewModelScope.launch { try { + val cachedCourseEnrollments = fileUtil.getObjectFromFile() + if (cachedCourseEnrollments == null) { + if (networkConnection.isOnline()) { + _uiState.value = DashboardGalleryUIState.Loading + } else { + _uiState.value = DashboardGalleryUIState.Empty + } + } else { + _uiState.value = + DashboardGalleryUIState.Courses( + cachedCourseEnrollments.mapToDomain(), + corePreferences.isRelativeDatesEnabled + ) + } if (networkConnection.isOnline()) { isLoading = true val pageSize = if (windowSize.isTablet) { @@ -83,17 +97,6 @@ class DashboardGalleryViewModel( corePreferences.isRelativeDatesEnabled ) } - } else { - val courseEnrollments = fileUtil.getObjectFromFile() - if (courseEnrollments == null) { - _uiState.value = DashboardGalleryUIState.Empty - } else { - _uiState.value = - DashboardGalleryUIState.Courses( - courseEnrollments.mapToDomain(), - corePreferences.isRelativeDatesEnabled - ) - } } } catch (e: Exception) { if (e.isInternetError()) { From 4dbff8b24938f649e5e1358ad6800b84cce1dae0 Mon Sep 17 00:00:00 2001 From: PavloNetrebchuk <141041606+PavloNetrebchuk@users.noreply.github.com> Date: Tue, 4 Mar 2025 13:18:31 +0200 Subject: [PATCH 03/24] feat: authorization request dialog (#431) --- .../java/org/openedx/core/ui/ComposeCommon.kt | 27 +- core/src/main/res/values/strings.xml | 2 + .../detail/AuthorizationDialogFragment.kt | 318 ++++++++++++++++++ .../detail/CourseDetailsFragment.kt | 9 +- 4 files changed, 339 insertions(+), 17 deletions(-) create mode 100644 discovery/src/main/java/org/openedx/discovery/presentation/detail/AuthorizationDialogFragment.kt 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 c2fad664d..aaaa0711d 100644 --- a/core/src/main/java/org/openedx/core/ui/ComposeCommon.kt +++ b/core/src/main/java/org/openedx/core/ui/ComposeCommon.kt @@ -1265,19 +1265,6 @@ fun AuthButtonsPanel( showRegisterButton: Boolean, ) { Row { - if (showRegisterButton) { - OpenEdXButton( - modifier = Modifier - .testTag("btn_register") - .width(0.dp) - .weight(1f), - text = stringResource(id = R.string.core_register), - textColor = MaterialTheme.appColors.primaryButtonText, - backgroundColor = MaterialTheme.appColors.secondaryButtonBackground, - onClick = { onRegisterClick() } - ) - } - OpenEdXOutlinedButton( modifier = Modifier .testTag("btn_sign_in") @@ -1285,7 +1272,7 @@ fun AuthButtonsPanel( if (showRegisterButton) { Modifier .width(100.dp) - .padding(start = 16.dp) + .padding(end = 16.dp) } else { Modifier.weight(1f) } @@ -1296,6 +1283,18 @@ fun AuthButtonsPanel( backgroundColor = MaterialTheme.appColors.secondaryButtonBorderedBackground, borderColor = MaterialTheme.appColors.secondaryButtonBorder, ) + if (showRegisterButton) { + OpenEdXButton( + modifier = Modifier + .testTag("btn_register") + .width(0.dp) + .weight(1f), + text = stringResource(id = R.string.core_register), + textColor = MaterialTheme.appColors.primaryButtonText, + backgroundColor = MaterialTheme.appColors.secondaryButtonBackground, + onClick = { onRegisterClick() } + ) + } } } diff --git a/core/src/main/res/values/strings.xml b/core/src/main/res/values/strings.xml index c8d529afa..f15a693bb 100644 --- a/core/src/main/res/values/strings.xml +++ b/core/src/main/res/values/strings.xml @@ -187,4 +187,6 @@ Not Synced Syncing to calendar… Next + Authorization + Please enter the system to continue with course enrollment. diff --git a/discovery/src/main/java/org/openedx/discovery/presentation/detail/AuthorizationDialogFragment.kt b/discovery/src/main/java/org/openedx/discovery/presentation/detail/AuthorizationDialogFragment.kt new file mode 100644 index 000000000..b6c7e18a8 --- /dev/null +++ b/discovery/src/main/java/org/openedx/discovery/presentation/detail/AuthorizationDialogFragment.kt @@ -0,0 +1,318 @@ +package org.openedx.discovery.presentation.detail + +import android.content.res.Configuration +import android.content.res.Configuration.UI_MODE_NIGHT_NO +import android.content.res.Configuration.UI_MODE_NIGHT_YES +import android.graphics.Color +import android.graphics.drawable.ColorDrawable +import android.os.Bundle +import android.view.LayoutInflater +import android.view.ViewGroup +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.material.Card +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.automirrored.filled.Login +import androidx.compose.material.icons.filled.Close +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.platform.ComposeView +import androidx.compose.ui.platform.LocalConfiguration +import androidx.compose.ui.platform.ViewCompositionStrategy +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.core.os.bundleOf +import androidx.fragment.app.DialogFragment +import org.koin.android.ext.android.inject +import org.openedx.core.ui.OpenEdXButton +import org.openedx.core.ui.OpenEdXOutlinedButton +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.discovery.presentation.DiscoveryRouter +import org.openedx.foundation.extension.setWidthPercent +import org.openedx.core.R as coreR + +class AuthorizationDialogFragment : DialogFragment() { + + private val router: DiscoveryRouter by inject() + + override fun onResume() { + super.onResume() + if (resources.configuration.orientation == Configuration.ORIENTATION_LANDSCAPE) { + setWidthPercent(percentage = LANDSCAPE_WIDTH_PERCENT) + } + } + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ) = ComposeView(requireContext()).apply { + dialog?.window?.setBackgroundDrawable(ColorDrawable(Color.TRANSPARENT)) + setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed) + setContent { + OpenEdXTheme { + val courseId = requireArguments().getString(ARG_COURSE_ID) ?: "" + AuthorizationDialogView( + onRegisterButtonClick = { + router.navigateToSignUp(requireActivity().supportFragmentManager, courseId) + dismiss() + }, + onSignInButtonClick = { + router.navigateToSignIn( + requireActivity().supportFragmentManager, + courseId, + null + ) + dismiss() + }, + onCancelButtonClick = { + dismiss() + } + ) + } + } + } + + companion object { + private const val ARG_COURSE_ID = "arg_course_id" + private const val LANDSCAPE_WIDTH_PERCENT = 66 + fun newInstance( + courseId: String, + ): AuthorizationDialogFragment { + val dialog = AuthorizationDialogFragment() + dialog.arguments = bundleOf( + ARG_COURSE_ID to courseId, + ) + return dialog + } + } +} + +@Composable +private fun AuthorizationDialogView( + onRegisterButtonClick: () -> Unit, + onSignInButtonClick: () -> Unit, + onCancelButtonClick: () -> Unit +) { + val configuration = LocalConfiguration.current + if (configuration.orientation == Configuration.ORIENTATION_PORTRAIT) { + AuthorizationDialogPortraitView( + onRegisterButtonClick = onRegisterButtonClick, + onSignInButtonClick = onSignInButtonClick, + onCancelButtonClick = onCancelButtonClick + ) + } else { + AuthorizationDialogLandscapeView( + onRegisterButtonClick = onRegisterButtonClick, + onSignInButtonClick = onSignInButtonClick, + onCancelButtonClick = onCancelButtonClick + ) + } +} + +@Composable +private fun AuthorizationDialogPortraitView( + onRegisterButtonClick: () -> Unit, + onSignInButtonClick: () -> Unit, + onCancelButtonClick: () -> Unit +) { + Card( + modifier = Modifier + .fillMaxWidth(fraction = 0.95f) + .clip(MaterialTheme.appShapes.courseImageShape), + backgroundColor = MaterialTheme.appColors.background, + shape = MaterialTheme.appShapes.courseImageShape + ) { + Column( + modifier = Modifier.padding(30.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Box( + contentAlignment = Alignment.CenterEnd, + modifier = Modifier.fillMaxWidth() + ) { + IconButton( + modifier = Modifier.size(24.dp), + onClick = onCancelButtonClick + ) { + Icon( + imageVector = Icons.Filled.Close, + contentDescription = stringResource(id = coreR.string.core_cancel), + tint = MaterialTheme.appColors.primary + ) + } + } + Icon( + modifier = Modifier + .width(76.dp) + .height(72.dp), + imageVector = Icons.AutoMirrored.Filled.Login, + contentDescription = null, + tint = MaterialTheme.appColors.onBackground + ) + Spacer(Modifier.height(36.dp)) + Text( + text = stringResource(id = coreR.string.core_authorization), + color = MaterialTheme.appColors.textPrimary, + style = MaterialTheme.appTypography.titleLarge + ) + Spacer(Modifier.height(8.dp)) + Text( + modifier = Modifier.fillMaxWidth(), + text = stringResource(id = coreR.string.core_authorization_request), + color = MaterialTheme.appColors.textFieldText, + style = MaterialTheme.appTypography.titleSmall, + textAlign = TextAlign.Center + ) + Spacer(Modifier.height(42.dp)) + Row { + OpenEdXOutlinedButton( + modifier = Modifier.weight(1f), + borderColor = MaterialTheme.appColors.primaryButtonBackground, + textColor = MaterialTheme.appColors.primaryButtonBackground, + text = stringResource(id = coreR.string.core_sign_in), + onClick = onSignInButtonClick + ) + Spacer(Modifier.width(16.dp)) + OpenEdXButton( + modifier = Modifier.weight(1f), + text = stringResource(id = coreR.string.core_register), + onClick = onRegisterButtonClick + ) + } + } + } +} + +@Composable +private fun AuthorizationDialogLandscapeView( + onRegisterButtonClick: () -> Unit, + onSignInButtonClick: () -> Unit, + onCancelButtonClick: () -> Unit +) { + Card( + modifier = Modifier + .fillMaxWidth() + .clip(MaterialTheme.appShapes.courseImageShape), + backgroundColor = MaterialTheme.appColors.background, + shape = MaterialTheme.appShapes.courseImageShape + ) { + Column( + modifier = Modifier.padding(38.dp) + ) { + Box( + contentAlignment = Alignment.CenterEnd, + modifier = Modifier + .fillMaxWidth() + .padding(bottom = 12.dp) + ) { + IconButton( + modifier = Modifier.size(24.dp), + onClick = onCancelButtonClick + ) { + Icon( + imageVector = Icons.Filled.Close, + contentDescription = stringResource(id = coreR.string.core_cancel), + tint = MaterialTheme.appColors.primary + ) + } + } + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween + ) { + Column( + Modifier.weight(1f), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Icon( + modifier = Modifier + .width(76.dp) + .height(72.dp), + imageVector = Icons.AutoMirrored.Filled.Login, + contentDescription = null, + tint = MaterialTheme.appColors.onBackground + ) + Spacer(Modifier.height(36.dp)) + Text( + modifier = Modifier.fillMaxWidth(), + text = stringResource(id = coreR.string.core_authorization), + color = MaterialTheme.appColors.textPrimary, + style = MaterialTheme.appTypography.titleLarge, + textAlign = TextAlign.Center + ) + Spacer(Modifier.height(8.dp)) + Text( + modifier = Modifier.fillMaxWidth(), + text = stringResource(id = coreR.string.core_authorization_request), + color = MaterialTheme.appColors.textFieldText, + style = MaterialTheme.appTypography.titleSmall, + textAlign = TextAlign.Center + ) + } + Spacer(Modifier.width(42.dp)) + Column( + Modifier.weight(1f), + horizontalAlignment = Alignment.CenterHorizontally + ) { + OpenEdXOutlinedButton( + borderColor = MaterialTheme.appColors.primaryButtonBackground, + textColor = MaterialTheme.appColors.primaryButtonBackground, + text = stringResource(id = coreR.string.core_sign_in), + onClick = onSignInButtonClick, + ) + Spacer(Modifier.height(16.dp)) + OpenEdXButton( + text = stringResource(id = coreR.string.core_register), + onClick = onRegisterButtonClick + ) + } + } + } + } +} + +@Preview(uiMode = UI_MODE_NIGHT_NO) +@Preview(uiMode = UI_MODE_NIGHT_YES) +@Composable +private fun AuthorizationDialogPortraitViewPreview() { + OpenEdXTheme { + AuthorizationDialogPortraitView( + onSignInButtonClick = {}, + onRegisterButtonClick = {}, + onCancelButtonClick = {} + ) + } +} + +@Preview(uiMode = UI_MODE_NIGHT_NO) +@Preview(uiMode = UI_MODE_NIGHT_YES) +@Composable +private fun AuthorizationDialogLandscapeViewPreview() { + OpenEdXTheme { + AuthorizationDialogLandscapeView( + onSignInButtonClick = {}, + onRegisterButtonClick = {}, + onCancelButtonClick = {} + ) + } +} 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 556f61459..d49f9e1c4 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 @@ -154,9 +154,12 @@ class CourseDetailsFragment : Fragment() { if (currentState is CourseDetailsUIState.CourseData) { when { (!currentState.isUserLoggedIn) -> { - router.navigateToLogistration( - parentFragmentManager, - currentState.course.courseId + val dialog = AuthorizationDialogFragment.newInstance( + viewModel.courseId + ) + dialog.show( + requireActivity().supportFragmentManager, + AuthorizationDialogFragment::class.simpleName ) } From d659f71d8cc12eec81a84e18642bfbec6f6ecd45 Mon Sep 17 00:00:00 2001 From: PavloNetrebchuk <141041606+PavloNetrebchuk@users.noreply.github.com> Date: Tue, 1 Apr 2025 11:48:09 +0300 Subject: [PATCH 04/24] feat: [FC-0078] Downloads page (#432) * feat: added downloads tab to main navigation * feat: course item UI * feat: download course list request * feat: downloads fragment empty state * feat: downloading logic * refactor: dynamic main menu * feat: show loading course structure state * feat: downloads analytic * feat: junit test * feat: navigate to course outline, swipe refresh on empty state * fix: changes according PR review * feat: show course item on dialog * refactor: improved code for better readability, optimized downloading logic * fix: dialog icon * fix: remove course size * feat: landscape and tablet ui * fix: remove course during downloading * fix: update download page after getting course structure on outline page * fix: update downloading state if new blocks was added * feat: added height limit and scroll to download dialog * feat: rename config flag * feat: fix available course size in download dialog. Improved logic of getting downloads models from room * feat: added school icon to DownloadDialogItem * fix: using real unarchived size instead bloc size * fix: junit tests * feat: navigation icons update * fix: changes according PR review * feat: put config to experimental value * fix: junit test fix --- app/build.gradle | 1 + .../java/org/openedx/app/AnalyticsManager.kt | 4 +- .../main/java/org/openedx/app/AppActivity.kt | 2 +- .../main/java/org/openedx/app/AppAnalytics.kt | 4 + .../main/java/org/openedx/app/AppRouter.kt | 4 +- .../main/java/org/openedx/app/MainFragment.kt | 164 +++-- .../java/org/openedx/app/MainViewModel.kt | 6 + .../java/org/openedx/app/deeplink/HomeTab.kt | 1 + .../main/java/org/openedx/app/di/AppModule.kt | 6 +- .../java/org/openedx/app/di/ScreenModule.kt | 49 +- .../java/org/openedx/app/room/AppDatabase.kt | 4 +- .../org/openedx/app/room/DatabaseManager.kt | 2 +- app/src/main/res/drawable/app_ic_book.xml | 45 -- .../{app_ic_rows.xml => app_ic_book_fill.xml} | 0 .../main/res/drawable/app_ic_book_outline.xml | 26 + .../res/drawable/app_ic_discover_selector.xml | 5 + .../drawable/app_ic_download_cloud_fill.xml | 9 + .../app_ic_download_cloud_outline.xml | 9 + .../drawable/app_ic_downloads_selector.xml | 5 + app/src/main/res/drawable/app_ic_home.xml | 38 -- .../res/drawable/app_ic_learn_selector.xml | 5 + app/src/main/res/drawable/app_ic_profile.xml | 31 - .../main/res/drawable/app_ic_profile_fill.xml | 9 + .../res/drawable/app_ic_profile_outline.xml | 12 + .../res/drawable/app_ic_profile_selector.xml | 5 + .../main/res/drawable/app_ic_search_fill.xml | 9 + .../res/drawable/app_ic_search_outline.xml | 9 + app/src/main/res/layout/fragment_main.xml | 3 +- app/src/main/res/menu/bottom_view_menu.xml | 22 - app/src/main/res/values/main_manu_tab_ids.xml | 7 + app/src/main/res/values/strings.xml | 1 + build.gradle | 1 + core/build.gradle | 3 + .../core/config/AppLevelDownloadsConfig.kt | 8 + .../java/org/openedx/core/config/Config.kt | 9 + .../core/config/ExperimentalFeaturesConfig.kt | 8 + .../org/openedx/core/data/api/CourseApi.kt | 6 + .../core/data/model/DownloadCoursePreview.kt | 34 ++ .../data/model/room/DownloadCoursePreview.kt | 28 + .../openedx/core}/data/storage/CourseDao.kt | 2 +- .../domain/interactor/CourseInteractor.kt | 15 + .../org/openedx/core/domain/model/Block.kt | 8 + .../domain/model/DownloadCoursePreview.kt | 8 + .../domain/model/DownloadDialogResource.kt | 2 +- .../org/openedx/core/module/DownloadWorker.kt | 20 +- .../org/openedx/core/module/db/DownloadDao.kt | 10 + .../openedx/core/module/db/DownloadModel.kt | 4 +- .../module/download/BaseDownloadViewModel.kt | 53 +- .../core/presentation/DownloadsAnalytics.kt | 49 ++ .../DownloadConfirmDialogFragment.kt | 52 +- .../DownloadConfirmDialogType.kt | 2 +- .../downloaddialog}/DownloadDialogItem.kt | 2 +- .../downloaddialog}/DownloadDialogManager.kt | 170 +++++- .../downloaddialog}/DownloadDialogUIState.kt | 6 +- .../DownloadErrorDialogFragment.kt | 51 +- .../DownloadErrorDialogType.kt | 2 +- .../DownloadStorageErrorDialogFragment.kt | 58 +- .../dialog/downloaddialog}/DownloadView.kt | 2 +- .../core/system/notifier/CourseNotifier.kt | 1 + .../system/notifier/CourseStructureGot.kt | 5 + .../java/org/openedx/core/ui/ComposeCommon.kt | 51 ++ .../res/drawable/core_download_waiting.png | Bin .../src/main/res/drawable/core_ic_error.xml | 0 core/src/main/res/values/strings.xml | 40 ++ .../data/repository/CourseRepository.kt | 8 +- .../domain/interactor/CourseInteractor.kt | 11 +- .../container/CourseContainerTab.kt | 4 +- .../container/CourseContainerViewModel.kt | 5 +- .../offline/CourseOfflineScreen.kt | 35 +- .../offline/CourseOfflineViewModel.kt | 25 +- .../outline/CourseOutlineViewModel.kt | 17 +- .../course/presentation/ui/CourseUI.kt | 6 +- .../course/presentation/ui/CourseVideosUI.kt | 23 +- .../unit/NotAvailableUnitFragment.kt | 10 +- .../videos/CourseVideoViewModel.kt | 31 +- .../download/DownloadQueueFragment.kt | 9 +- .../download/DownloadQueueViewModel.kt | 3 +- .../src/main/res/drawable/core_ic_error.xml | 5 - course/src/main/res/values/strings.xml | 38 -- .../outline/CourseOutlineViewModelTest.kt | 10 +- .../videos/CourseVideoViewModelTest.kt | 170 +++--- .../learn/presentation/LearnFragment.kt | 43 +- default_config/dev/config.yaml | 4 + default_config/prod/config.yaml | 4 + default_config/stage/config.yaml | 4 + downloads/.gitignore | 1 + downloads/build.gradle | 65 ++ downloads/consumer-rules.pro | 0 downloads/proguard-rules.pro | 7 + downloads/src/main/AndroidManifest.xml | 4 + .../data/repository/DownloadRepository.kt | 56 ++ .../domain/interactor/DownloadInteractor.kt | 17 + .../downloads/presentation/DownloadsRouter.kt | 14 + .../download/DownloadsFragment.kt | 78 +++ .../presentation/download/DownloadsScreen.kt | 570 ++++++++++++++++++ .../presentation/download/DownloadsUIState.kt | 13 + .../download/DownloadsViewModel.kt | 386 ++++++++++++ downloads/src/main/res/values/strings.xml | 13 + .../downloads/DownloadsViewModelTest.kt | 394 ++++++++++++ settings.gradle | 1 + 100 files changed, 2730 insertions(+), 561 deletions(-) delete mode 100644 app/src/main/res/drawable/app_ic_book.xml rename app/src/main/res/drawable/{app_ic_rows.xml => app_ic_book_fill.xml} (100%) create mode 100644 app/src/main/res/drawable/app_ic_book_outline.xml create mode 100644 app/src/main/res/drawable/app_ic_discover_selector.xml create mode 100644 app/src/main/res/drawable/app_ic_download_cloud_fill.xml create mode 100644 app/src/main/res/drawable/app_ic_download_cloud_outline.xml create mode 100644 app/src/main/res/drawable/app_ic_downloads_selector.xml delete mode 100644 app/src/main/res/drawable/app_ic_home.xml create mode 100644 app/src/main/res/drawable/app_ic_learn_selector.xml delete mode 100644 app/src/main/res/drawable/app_ic_profile.xml create mode 100644 app/src/main/res/drawable/app_ic_profile_fill.xml create mode 100644 app/src/main/res/drawable/app_ic_profile_outline.xml create mode 100644 app/src/main/res/drawable/app_ic_profile_selector.xml create mode 100644 app/src/main/res/drawable/app_ic_search_fill.xml create mode 100644 app/src/main/res/drawable/app_ic_search_outline.xml delete mode 100644 app/src/main/res/menu/bottom_view_menu.xml create mode 100644 app/src/main/res/values/main_manu_tab_ids.xml create mode 100644 core/src/main/java/org/openedx/core/config/AppLevelDownloadsConfig.kt create mode 100644 core/src/main/java/org/openedx/core/config/ExperimentalFeaturesConfig.kt create mode 100644 core/src/main/java/org/openedx/core/data/model/DownloadCoursePreview.kt create mode 100644 core/src/main/java/org/openedx/core/data/model/room/DownloadCoursePreview.kt rename {course/src/main/java/org/openedx/course => core/src/main/java/org/openedx/core}/data/storage/CourseDao.kt (96%) create mode 100644 core/src/main/java/org/openedx/core/domain/interactor/CourseInteractor.kt create mode 100644 core/src/main/java/org/openedx/core/domain/model/DownloadCoursePreview.kt rename {course/src/main/java/org/openedx/course => core/src/main/java/org/openedx/core}/domain/model/DownloadDialogResource.kt (81%) create mode 100644 core/src/main/java/org/openedx/core/presentation/DownloadsAnalytics.kt rename {course/src/main/java/org/openedx/course/presentation/download => core/src/main/java/org/openedx/core/presentation/dialog/downloaddialog}/DownloadConfirmDialogFragment.kt (85%) rename {course/src/main/java/org/openedx/course/presentation/download => core/src/main/java/org/openedx/core/presentation/dialog/downloaddialog}/DownloadConfirmDialogType.kt (74%) rename {course/src/main/java/org/openedx/course/presentation/download => core/src/main/java/org/openedx/core/presentation/dialog/downloaddialog}/DownloadDialogItem.kt (83%) rename {course/src/main/java/org/openedx/course/presentation/download => core/src/main/java/org/openedx/core/presentation/dialog/downloaddialog}/DownloadDialogManager.kt (57%) rename {course/src/main/java/org/openedx/course/presentation/download => core/src/main/java/org/openedx/core/presentation/dialog/downloaddialog}/DownloadDialogUIState.kt (71%) rename {course/src/main/java/org/openedx/course/presentation/download => core/src/main/java/org/openedx/core/presentation/dialog/downloaddialog}/DownloadErrorDialogFragment.kt (85%) rename {course/src/main/java/org/openedx/course/presentation/download => core/src/main/java/org/openedx/core/presentation/dialog/downloaddialog}/DownloadErrorDialogType.kt (74%) rename {course/src/main/java/org/openedx/course/presentation/download => core/src/main/java/org/openedx/core/presentation/dialog/downloaddialog}/DownloadStorageErrorDialogFragment.kt (83%) rename {course/src/main/java/org/openedx/course/presentation/download => core/src/main/java/org/openedx/core/presentation/dialog/downloaddialog}/DownloadView.kt (97%) create mode 100644 core/src/main/java/org/openedx/core/system/notifier/CourseStructureGot.kt rename course/src/main/res/drawable/course_download_waiting.png => core/src/main/res/drawable/core_download_waiting.png (100%) rename course/src/main/res/drawable/course_ic_error.xml => core/src/main/res/drawable/core_ic_error.xml (100%) delete mode 100644 course/src/main/res/drawable/core_ic_error.xml create mode 100644 downloads/.gitignore create mode 100644 downloads/build.gradle create mode 100644 downloads/consumer-rules.pro create mode 100644 downloads/proguard-rules.pro create mode 100644 downloads/src/main/AndroidManifest.xml create mode 100644 downloads/src/main/java/org/openedx/downloads/data/repository/DownloadRepository.kt create mode 100644 downloads/src/main/java/org/openedx/downloads/domain/interactor/DownloadInteractor.kt create mode 100644 downloads/src/main/java/org/openedx/downloads/presentation/DownloadsRouter.kt create mode 100644 downloads/src/main/java/org/openedx/downloads/presentation/download/DownloadsFragment.kt create mode 100644 downloads/src/main/java/org/openedx/downloads/presentation/download/DownloadsScreen.kt create mode 100644 downloads/src/main/java/org/openedx/downloads/presentation/download/DownloadsUIState.kt create mode 100644 downloads/src/main/java/org/openedx/downloads/presentation/download/DownloadsViewModel.kt create mode 100644 downloads/src/main/res/values/strings.xml create mode 100644 downloads/src/test/java/org/openedx/downloads/DownloadsViewModelTest.kt diff --git a/app/build.gradle b/app/build.gradle index e863910ef..2c17ea1c1 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -125,6 +125,7 @@ dependencies { implementation project(path: ':profile') implementation project(path: ':discussion') implementation project(path: ':whatsnew') + implementation project(path: ':downloads') ksp "androidx.room:room-compiler:$room_version" diff --git a/app/src/main/java/org/openedx/app/AnalyticsManager.kt b/app/src/main/java/org/openedx/app/AnalyticsManager.kt index 138692348..6c29cdf12 100644 --- a/app/src/main/java/org/openedx/app/AnalyticsManager.kt +++ b/app/src/main/java/org/openedx/app/AnalyticsManager.kt @@ -2,6 +2,7 @@ package org.openedx.app import org.openedx.auth.presentation.AuthAnalytics import org.openedx.core.presentation.CoreAnalytics +import org.openedx.core.presentation.DownloadsAnalytics import org.openedx.core.presentation.dialog.appreview.AppReviewAnalytics import org.openedx.course.presentation.CourseAnalytics import org.openedx.dashboard.presentation.DashboardAnalytics @@ -21,7 +22,8 @@ class AnalyticsManager : DiscoveryAnalytics, DiscussionAnalytics, ProfileAnalytics, - WhatsNewAnalytics { + WhatsNewAnalytics, + DownloadsAnalytics { private val analytics: MutableList = mutableListOf() diff --git a/app/src/main/java/org/openedx/app/AppActivity.kt b/app/src/main/java/org/openedx/app/AppActivity.kt index 19c096338..cbb496501 100644 --- a/app/src/main/java/org/openedx/app/AppActivity.kt +++ b/app/src/main/java/org/openedx/app/AppActivity.kt @@ -27,11 +27,11 @@ import org.openedx.auth.presentation.logistration.LogistrationFragment import org.openedx.auth.presentation.signin.SignInFragment import org.openedx.core.ApiConstants import org.openedx.core.data.storage.CorePreferences +import org.openedx.core.presentation.dialog.downloaddialog.DownloadDialogManager import org.openedx.core.presentation.global.InsetHolder import org.openedx.core.presentation.global.WindowSizeHolder import org.openedx.core.utils.Logger import org.openedx.core.worker.CalendarSyncScheduler -import org.openedx.course.presentation.download.DownloadDialogManager import org.openedx.foundation.extension.requestApplyInsetsWhenAttached import org.openedx.foundation.presentation.WindowSize import org.openedx.foundation.presentation.WindowType diff --git a/app/src/main/java/org/openedx/app/AppAnalytics.kt b/app/src/main/java/org/openedx/app/AppAnalytics.kt index 0fe3ed4be..55b26b492 100644 --- a/app/src/main/java/org/openedx/app/AppAnalytics.kt +++ b/app/src/main/java/org/openedx/app/AppAnalytics.kt @@ -20,6 +20,10 @@ enum class AppAnalyticsEvent(val eventName: String, val biValue: String) { "MainDashboard:Discover", "edx.bi.app.main_dashboard.discover" ), + DOWNLOADS( + "MainDashboard:Downloads", + "edx.bi.app.main_dashboard.downloads" + ), PROFILE( "MainDashboard:Profile", "edx.bi.app.main_dashboard.profile" diff --git a/app/src/main/java/org/openedx/app/AppRouter.kt b/app/src/main/java/org/openedx/app/AppRouter.kt index 0130d6b31..cfe1ecc44 100644 --- a/app/src/main/java/org/openedx/app/AppRouter.kt +++ b/app/src/main/java/org/openedx/app/AppRouter.kt @@ -44,6 +44,7 @@ import org.openedx.discussion.presentation.responses.DiscussionResponsesFragment import org.openedx.discussion.presentation.search.DiscussionSearchThreadFragment import org.openedx.discussion.presentation.threads.DiscussionAddThreadFragment import org.openedx.discussion.presentation.threads.DiscussionThreadsFragment +import org.openedx.downloads.presentation.DownloadsRouter import org.openedx.profile.domain.model.Account import org.openedx.profile.presentation.ProfileRouter import org.openedx.profile.presentation.anothersaccount.AnothersProfileFragment @@ -67,7 +68,8 @@ class AppRouter : ProfileRouter, AppUpgradeRouter, WhatsNewRouter, - CalendarRouter { + CalendarRouter, + DownloadsRouter { // region AuthRouter override fun navigateToMain( diff --git a/app/src/main/java/org/openedx/app/MainFragment.kt b/app/src/main/java/org/openedx/app/MainFragment.kt index 3ab735d27..c2b5041c7 100644 --- a/app/src/main/java/org/openedx/app/MainFragment.kt +++ b/app/src/main/java/org/openedx/app/MainFragment.kt @@ -1,6 +1,7 @@ package org.openedx.app import android.os.Bundle +import android.view.Menu import android.view.View import androidx.core.os.bundleOf import androidx.core.view.forEach @@ -17,6 +18,7 @@ import org.openedx.core.adapter.NavigationFragmentAdapter import org.openedx.core.presentation.global.appupgrade.UpgradeRequiredFragment import org.openedx.core.presentation.global.viewBinding import org.openedx.discovery.presentation.DiscoveryRouter +import org.openedx.downloads.presentation.download.DownloadsFragment import org.openedx.learn.presentation.LearnFragment import org.openedx.learn.presentation.LearnTab import org.openedx.profile.presentation.profile.ProfileFragment @@ -40,29 +42,104 @@ class MainFragment : Fragment(R.layout.fragment_main) { override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) + handleArguments() + setupBottomNavigation() + setupViewPager() + observeViewModel() + } - initViewPager() - - binding.bottomNavView.setOnItemSelectedListener { - when (it.itemId) { - R.id.fragmentLearn -> { - viewModel.logLearnTabClickedEvent() - binding.viewPager.setCurrentItem(0, false) + private fun handleArguments() { + requireArguments().apply { + getString(ARG_COURSE_ID).takeIf { it.isNullOrBlank().not() }?.let { courseId -> + val infoType = getString(ARG_INFO_TYPE) + if (viewModel.isDiscoveryTypeWebView && infoType != null) { + router.navigateToCourseInfo(parentFragmentManager, courseId, infoType) + } else { + router.navigateToCourseDetail(parentFragmentManager, courseId) } + putString(ARG_COURSE_ID, "") + putString(ARG_INFO_TYPE, "") + } + } + } - R.id.fragmentDiscover -> { - viewModel.logDiscoveryTabClickedEvent() - binding.viewPager.setCurrentItem(1, false) - } + private fun setupBottomNavigation() { + val openTabArg = requireArguments().getString(ARG_OPEN_TAB, HomeTab.LEARN.name) + val initialMenuId = getInitialMenuId(openTabArg) + binding.bottomNavView.selectedItemId = initialMenuId - R.id.fragmentProfile -> { - viewModel.logProfileTabClickedEvent() - binding.viewPager.setCurrentItem(2, false) - } + val menu = binding.bottomNavView.menu + menu.clear() + + val tabList = createTabList(openTabArg) + addMenuItems(menu, tabList) + setupBottomNavListener(tabList) + + requireArguments().remove(ARG_OPEN_TAB) + } + + private fun createTabList(openTabArg: String): List> { + val learnFragment = LearnFragment.newInstance( + openTab = if (openTabArg == HomeTab.PROGRAMS.name) { + LearnTab.PROGRAMS.name + } else { + LearnTab.COURSES.name + } + ) + + return mutableListOf>().apply { + add(R.id.fragmentLearn to learnFragment) + add(R.id.fragmentDiscover to viewModel.getDiscoveryFragment) + if (viewModel.isDownloadsFragmentEnabled) { + add(R.id.fragmentDownloads to DownloadsFragment()) + } + add(R.id.fragmentProfile to ProfileFragment()) + } + } + + private fun addMenuItems(menu: Menu, tabList: List>) { + val tabTitles = mapOf( + R.id.fragmentLearn to resources.getString(R.string.app_navigation_learn), + R.id.fragmentDiscover to resources.getString(R.string.app_navigation_discovery), + R.id.fragmentDownloads to resources.getString(R.string.app_navigation_downloads), + R.id.fragmentProfile to resources.getString(R.string.app_navigation_profile), + ) + val tabIconSelectors = mapOf( + R.id.fragmentLearn to R.drawable.app_ic_learn_selector, + R.id.fragmentDiscover to R.drawable.app_ic_discover_selector, + R.id.fragmentDownloads to R.drawable.app_ic_downloads_selector, + R.id.fragmentProfile to R.drawable.app_ic_profile_selector + ) + + for ((id, _) in tabList) { + val menuItem = menu.add(Menu.NONE, id, Menu.NONE, tabTitles[id] ?: "") + tabIconSelectors[id]?.let { menuItem.setIcon(it) } + } + } + + private fun setupBottomNavListener(tabList: List>) { + val menuIdToIndex = tabList.mapIndexed { index, pair -> pair.first to index }.toMap() + + binding.bottomNavView.setOnItemSelectedListener { menuItem -> + when (menuItem.itemId) { + R.id.fragmentLearn -> viewModel.logLearnTabClickedEvent() + R.id.fragmentDiscover -> viewModel.logDiscoveryTabClickedEvent() + R.id.fragmentDownloads -> viewModel.logDownloadsTabClickedEvent() + R.id.fragmentProfile -> viewModel.logProfileTabClickedEvent() + } + menuIdToIndex[menuItem.itemId]?.let { index -> + binding.viewPager.setCurrentItem(index, false) } true } + } + private fun setupViewPager() { + val tabList = createTabList(requireArguments().getString(ARG_OPEN_TAB, HomeTab.LEARN.name)) + initViewPager(tabList) + } + + private fun observeViewModel() { viewModel.isBottomBarEnabled.observe(viewLifecycleOwner) { isBottomBarEnabled -> enableBottomBar(isBottomBarEnabled) } @@ -74,55 +151,30 @@ class MainFragment : Fragment(R.layout.fragment_main) { } } } + } - requireArguments().apply { - getString(ARG_COURSE_ID).takeIf { it.isNullOrBlank().not() }?.let { courseId -> - val infoType = getString(ARG_INFO_TYPE) - - if (viewModel.isDiscoveryTypeWebView && infoType != null) { - router.navigateToCourseInfo(parentFragmentManager, courseId, infoType) - } else { - router.navigateToCourseDetail(parentFragmentManager, courseId) - } - - // Clear arguments after navigation - putString(ARG_COURSE_ID, "") - putString(ARG_INFO_TYPE, "") - } - - when (requireArguments().getString(ARG_OPEN_TAB, "")) { - HomeTab.LEARN.name, - HomeTab.PROGRAMS.name -> { - binding.bottomNavView.selectedItemId = R.id.fragmentLearn - } - - HomeTab.DISCOVER.name -> { - binding.bottomNavView.selectedItemId = R.id.fragmentDiscover - } - - HomeTab.PROFILE.name -> { - binding.bottomNavView.selectedItemId = R.id.fragmentProfile - } + private fun getInitialMenuId(openTabArg: String): Int { + return when (openTabArg) { + HomeTab.LEARN.name, HomeTab.PROGRAMS.name -> R.id.fragmentLearn + HomeTab.DISCOVER.name -> R.id.fragmentDiscover + HomeTab.DOWNLOADS.name -> if (viewModel.isDownloadsFragmentEnabled) { + R.id.fragmentDownloads + } else { + R.id.fragmentLearn } - requireArguments().remove(ARG_OPEN_TAB) + HomeTab.PROFILE.name -> R.id.fragmentProfile + else -> R.id.fragmentLearn } } - @Suppress("MagicNumber") - private fun initViewPager() { + private fun initViewPager(tabList: List>) { binding.viewPager.orientation = ViewPager2.ORIENTATION_HORIZONTAL - binding.viewPager.offscreenPageLimit = 4 + binding.viewPager.offscreenPageLimit = tabList.size - val openTab = requireArguments().getString(ARG_OPEN_TAB, HomeTab.LEARN.name) - val learnTab = if (openTab == HomeTab.PROGRAMS.name) { - LearnTab.PROGRAMS - } else { - LearnTab.COURSES - } adapter = NavigationFragmentAdapter(this).apply { - addFragment(LearnFragment.newInstance(openTab = learnTab.name)) - addFragment(viewModel.getDiscoveryFragment) - addFragment(ProfileFragment()) + tabList.forEach { (_, fragment) -> + addFragment(fragment) + } } binding.viewPager.adapter = adapter binding.viewPager.isUserInputEnabled = false diff --git a/app/src/main/java/org/openedx/app/MainViewModel.kt b/app/src/main/java/org/openedx/app/MainViewModel.kt index 69c809b5c..2d2033769 100644 --- a/app/src/main/java/org/openedx/app/MainViewModel.kt +++ b/app/src/main/java/org/openedx/app/MainViewModel.kt @@ -33,6 +33,8 @@ class MainViewModel( val isDiscoveryTypeWebView get() = config.getDiscoveryConfig().isViewTypeWebView() val getDiscoveryFragment get() = DiscoveryNavigator(isDiscoveryTypeWebView).getDiscoveryFragment() + val isDownloadsFragmentEnabled get() = config.getDownloadsConfig().isEnabled + override fun onCreate(owner: LifecycleOwner) { super.onCreate(owner) notifier.notifier @@ -57,6 +59,10 @@ class MainViewModel( logScreenEvent(AppAnalyticsEvent.DISCOVER) } + fun logDownloadsTabClickedEvent() { + logScreenEvent(AppAnalyticsEvent.DOWNLOADS) + } + fun logProfileTabClickedEvent() { logScreenEvent(AppAnalyticsEvent.PROFILE) } diff --git a/app/src/main/java/org/openedx/app/deeplink/HomeTab.kt b/app/src/main/java/org/openedx/app/deeplink/HomeTab.kt index c020cf636..ce72703ad 100644 --- a/app/src/main/java/org/openedx/app/deeplink/HomeTab.kt +++ b/app/src/main/java/org/openedx/app/deeplink/HomeTab.kt @@ -4,5 +4,6 @@ enum class HomeTab { LEARN, PROGRAMS, DISCOVER, + DOWNLOADS, 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 ce6e20cd9..b4633cc27 100644 --- a/app/src/main/java/org/openedx/app/di/AppModule.kt +++ b/app/src/main/java/org/openedx/app/di/AppModule.kt @@ -40,8 +40,10 @@ import org.openedx.core.module.TranscriptManager import org.openedx.core.module.download.DownloadHelper import org.openedx.core.module.download.FileDownloader import org.openedx.core.presentation.CoreAnalytics +import org.openedx.core.presentation.DownloadsAnalytics import org.openedx.core.presentation.dialog.appreview.AppReviewAnalytics import org.openedx.core.presentation.dialog.appreview.AppReviewManager +import org.openedx.core.presentation.dialog.downloaddialog.DownloadDialogManager import org.openedx.core.presentation.global.AppData import org.openedx.core.presentation.global.WhatsNewGlobalManager import org.openedx.core.presentation.global.appupgrade.AppUpgradeRouter @@ -58,7 +60,6 @@ import org.openedx.core.worker.CalendarSyncScheduler import org.openedx.course.data.storage.CoursePreferences import org.openedx.course.presentation.CourseAnalytics import org.openedx.course.presentation.CourseRouter -import org.openedx.course.presentation.download.DownloadDialogManager import org.openedx.course.utils.ImageProcessor import org.openedx.course.worker.OfflineProgressSyncScheduler import org.openedx.dashboard.presentation.DashboardAnalytics @@ -68,6 +69,7 @@ import org.openedx.discovery.presentation.DiscoveryRouter import org.openedx.discussion.presentation.DiscussionAnalytics import org.openedx.discussion.presentation.DiscussionRouter import org.openedx.discussion.system.notifier.DiscussionNotifier +import org.openedx.downloads.presentation.DownloadsRouter import org.openedx.foundation.system.ResourceManager import org.openedx.foundation.utils.FileUtil import org.openedx.profile.data.storage.ProfilePreferences @@ -127,6 +129,7 @@ val appModule = module { single { get() } single { DeepLinkRouter(get(), get(), get(), get(), get(), get()) } single { get() } + single { get() } single { NetworkConnection(get()) } @@ -205,6 +208,7 @@ val appModule = module { single { get() } single { get() } single { get() } + single { get() } factory { AgreementProvider(get(), get()) } factory { FacebookAuthHelper() } 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 6b7692f99..d00d0f1fe 100644 --- a/app/src/main/java/org/openedx/app/di/ScreenModule.kt +++ b/app/src/main/java/org/openedx/app/di/ScreenModule.kt @@ -54,6 +54,9 @@ 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.downloads.data.repository.DownloadRepository +import org.openedx.downloads.domain.interactor.DownloadInteractor +import org.openedx.downloads.presentation.download.DownloadsViewModel import org.openedx.foundation.presentation.WindowSize import org.openedx.learn.presentation.LearnViewModel import org.openedx.profile.data.repository.ProfileRepository @@ -190,7 +193,16 @@ val screenModule = module { profileRouter = get(), ) } - viewModel { (account: Account) -> EditProfileViewModel(get(), get(), get(), get(), get(), account) } + viewModel { (account: Account) -> + EditProfileViewModel( + get(), + get(), + get(), + get(), + get(), + account + ) + } viewModel { VideoSettingsViewModel(get(), get(), get(), get()) } viewModel { (qualityType: String) -> VideoQualityViewModel(qualityType, get(), get(), get()) } viewModel { DeleteProfileViewModel(get(), get(), get(), get(), get()) } @@ -220,6 +232,7 @@ val screenModule = module { single { CourseRepository(get(), get(), get(), get(), get()) } factory { CourseInteractor(get()) } + single { get() } viewModel { (pathId: String, infoType: String) -> CourseInfoViewModel( @@ -482,4 +495,38 @@ val screenModule = module { get(), ) } + + single { + DownloadRepository( + api = get(), + corePreferences = get(), + dao = get(), + courseDao = get() + ) + } + single { + DownloadInteractor( + repository = get() + ) + } + viewModel { + DownloadsViewModel( + downloadsRouter = get(), + networkConnection = get(), + interactor = get(), + resourceManager = get(), + config = get(), + preferencesManager = get(), + coreAnalytics = get(), + downloadDao = get(), + workerController = get(), + downloadHelper = get(), + downloadDialogManager = get(), + fileUtil = get(), + analytics = get(), + discoveryNotifier = get(), + courseNotifier = get(), + router = get() + ) + } } diff --git a/app/src/main/java/org/openedx/app/room/AppDatabase.kt b/app/src/main/java/org/openedx/app/room/AppDatabase.kt index eec5b1811..bfdcee43f 100644 --- a/app/src/main/java/org/openedx/app/room/AppDatabase.kt +++ b/app/src/main/java/org/openedx/app/room/AppDatabase.kt @@ -8,13 +8,14 @@ import org.openedx.core.data.model.room.CourseCalendarEventEntity import org.openedx.core.data.model.room.CourseCalendarStateEntity import org.openedx.core.data.model.room.CourseEnrollmentDetailsEntity import org.openedx.core.data.model.room.CourseStructureEntity +import org.openedx.core.data.model.room.DownloadCoursePreview import org.openedx.core.data.model.room.OfflineXBlockProgress import org.openedx.core.data.model.room.discovery.EnrolledCourseEntity +import org.openedx.core.data.storage.CourseDao import org.openedx.core.module.db.CalendarDao import org.openedx.core.module.db.DownloadDao import org.openedx.core.module.db.DownloadModelEntity import org.openedx.course.data.storage.CourseConverter -import org.openedx.course.data.storage.CourseDao import org.openedx.dashboard.data.DashboardDao import org.openedx.discovery.data.converter.DiscoveryConverter import org.openedx.discovery.data.model.room.CourseEntity @@ -32,6 +33,7 @@ const val DATABASE_NAME = "OpenEdX_db" OfflineXBlockProgress::class, CourseCalendarEventEntity::class, CourseCalendarStateEntity::class, + DownloadCoursePreview::class, CourseEnrollmentDetailsEntity::class ], autoMigrations = [ diff --git a/app/src/main/java/org/openedx/app/room/DatabaseManager.kt b/app/src/main/java/org/openedx/app/room/DatabaseManager.kt index bcc123763..d24eb54f9 100644 --- a/app/src/main/java/org/openedx/app/room/DatabaseManager.kt +++ b/app/src/main/java/org/openedx/app/room/DatabaseManager.kt @@ -4,8 +4,8 @@ import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import org.openedx.core.DatabaseManager +import org.openedx.core.data.storage.CourseDao import org.openedx.core.module.db.DownloadDao -import org.openedx.course.data.storage.CourseDao import org.openedx.dashboard.data.DashboardDao import org.openedx.discovery.data.storage.DiscoveryDao diff --git a/app/src/main/res/drawable/app_ic_book.xml b/app/src/main/res/drawable/app_ic_book.xml deleted file mode 100644 index 4245846af..000000000 --- a/app/src/main/res/drawable/app_ic_book.xml +++ /dev/null @@ -1,45 +0,0 @@ - - - - - - - - - - diff --git a/app/src/main/res/drawable/app_ic_rows.xml b/app/src/main/res/drawable/app_ic_book_fill.xml similarity index 100% rename from app/src/main/res/drawable/app_ic_rows.xml rename to app/src/main/res/drawable/app_ic_book_fill.xml diff --git a/app/src/main/res/drawable/app_ic_book_outline.xml b/app/src/main/res/drawable/app_ic_book_outline.xml new file mode 100644 index 000000000..58021d21f --- /dev/null +++ b/app/src/main/res/drawable/app_ic_book_outline.xml @@ -0,0 +1,26 @@ + + + + + + + diff --git a/app/src/main/res/drawable/app_ic_discover_selector.xml b/app/src/main/res/drawable/app_ic_discover_selector.xml new file mode 100644 index 000000000..9d2d2a951 --- /dev/null +++ b/app/src/main/res/drawable/app_ic_discover_selector.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/app_ic_download_cloud_fill.xml b/app/src/main/res/drawable/app_ic_download_cloud_fill.xml new file mode 100644 index 000000000..8e623dc60 --- /dev/null +++ b/app/src/main/res/drawable/app_ic_download_cloud_fill.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/app_ic_download_cloud_outline.xml b/app/src/main/res/drawable/app_ic_download_cloud_outline.xml new file mode 100644 index 000000000..193cc1a6a --- /dev/null +++ b/app/src/main/res/drawable/app_ic_download_cloud_outline.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/app_ic_downloads_selector.xml b/app/src/main/res/drawable/app_ic_downloads_selector.xml new file mode 100644 index 000000000..a24c486d5 --- /dev/null +++ b/app/src/main/res/drawable/app_ic_downloads_selector.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/app_ic_home.xml b/app/src/main/res/drawable/app_ic_home.xml deleted file mode 100644 index b703f9f28..000000000 --- a/app/src/main/res/drawable/app_ic_home.xml +++ /dev/null @@ -1,38 +0,0 @@ - - - - - - - - - diff --git a/app/src/main/res/drawable/app_ic_learn_selector.xml b/app/src/main/res/drawable/app_ic_learn_selector.xml new file mode 100644 index 000000000..d3077a298 --- /dev/null +++ b/app/src/main/res/drawable/app_ic_learn_selector.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/app_ic_profile.xml b/app/src/main/res/drawable/app_ic_profile.xml deleted file mode 100644 index 1b241a689..000000000 --- a/app/src/main/res/drawable/app_ic_profile.xml +++ /dev/null @@ -1,31 +0,0 @@ - - - - - - - - diff --git a/app/src/main/res/drawable/app_ic_profile_fill.xml b/app/src/main/res/drawable/app_ic_profile_fill.xml new file mode 100644 index 000000000..c4ed432a2 --- /dev/null +++ b/app/src/main/res/drawable/app_ic_profile_fill.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/app_ic_profile_outline.xml b/app/src/main/res/drawable/app_ic_profile_outline.xml new file mode 100644 index 000000000..07226fc2b --- /dev/null +++ b/app/src/main/res/drawable/app_ic_profile_outline.xml @@ -0,0 +1,12 @@ + + + + diff --git a/app/src/main/res/drawable/app_ic_profile_selector.xml b/app/src/main/res/drawable/app_ic_profile_selector.xml new file mode 100644 index 000000000..83708d080 --- /dev/null +++ b/app/src/main/res/drawable/app_ic_profile_selector.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/app_ic_search_fill.xml b/app/src/main/res/drawable/app_ic_search_fill.xml new file mode 100644 index 000000000..6635fc8b1 --- /dev/null +++ b/app/src/main/res/drawable/app_ic_search_fill.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/app_ic_search_outline.xml b/app/src/main/res/drawable/app_ic_search_outline.xml new file mode 100644 index 000000000..4372bd085 --- /dev/null +++ b/app/src/main/res/drawable/app_ic_search_outline.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/layout/fragment_main.xml b/app/src/main/res/layout/fragment_main.xml index 9794b7bd7..9a4861379 100644 --- a/app/src/main/res/layout/fragment_main.xml +++ b/app/src/main/res/layout/fragment_main.xml @@ -25,7 +25,6 @@ app:layout_constraintBottom_toBottomOf="parent" app:layout_constraintEnd_toEndOf="parent" app:layout_constraintHorizontal_bias="0.5" - app:layout_constraintStart_toStartOf="parent" - app:menu="@menu/bottom_view_menu" /> + app:layout_constraintStart_toStartOf="parent" /> diff --git a/app/src/main/res/menu/bottom_view_menu.xml b/app/src/main/res/menu/bottom_view_menu.xml deleted file mode 100644 index f97e849f7..000000000 --- a/app/src/main/res/menu/bottom_view_menu.xml +++ /dev/null @@ -1,22 +0,0 @@ - - - - - - - - - - diff --git a/app/src/main/res/values/main_manu_tab_ids.xml b/app/src/main/res/values/main_manu_tab_ids.xml new file mode 100644 index 000000000..f769b5bde --- /dev/null +++ b/app/src/main/res/values/main_manu_tab_ids.xml @@ -0,0 +1,7 @@ + + + + + + + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index baa1c2a89..bfffb806e 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -7,4 +7,5 @@ Learn Programs Profile + Downloads diff --git a/build.gradle b/build.gradle index 390d02699..f7fb3cf91 100644 --- a/build.gradle +++ b/build.gradle @@ -44,6 +44,7 @@ ext { zip_version = '2.6.3' //testing + compose_ui_tooling = '1.7.8' mockk_version = '1.13.12' android_arch_version = '2.2.0' junit_version = '4.13.2' diff --git a/core/build.gradle b/core/build.gradle index f1ae6be5e..db0ce4bb1 100644 --- a/core/build.gradle +++ b/core/build.gradle @@ -119,6 +119,9 @@ dependencies { // OpenEdx libs api("com.github.openedx:openedx-app-foundation-android:1.0.0") + // Preview + debugApi "androidx.compose.ui:ui-tooling:$compose_ui_tooling" + testImplementation 'junit:junit:4.13.2' androidTestImplementation 'androidx.test.ext:junit:1.2.1' androidTestImplementation 'androidx.test.espresso:espresso-core:3.6.1' diff --git a/core/src/main/java/org/openedx/core/config/AppLevelDownloadsConfig.kt b/core/src/main/java/org/openedx/core/config/AppLevelDownloadsConfig.kt new file mode 100644 index 000000000..577f297c6 --- /dev/null +++ b/core/src/main/java/org/openedx/core/config/AppLevelDownloadsConfig.kt @@ -0,0 +1,8 @@ +package org.openedx.core.config + +import com.google.gson.annotations.SerializedName + +data class AppLevelDownloadsConfig( + @SerializedName("ENABLED") + val isEnabled: Boolean = true, +) 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 f240b9531..d26741699 100644 --- a/core/src/main/java/org/openedx/core/config/Config.kt +++ b/core/src/main/java/org/openedx/core/config/Config.kt @@ -92,6 +92,10 @@ class Config(context: Context) { return getObjectOrNewInstance(DASHBOARD, DashboardConfig::class.java) } + fun getDownloadsConfig(): AppLevelDownloadsConfig { + return getExperimentalFeaturesConfig().appLevelDownloadsConfig + } + fun getBranchConfig(): BranchConfig { return getObjectOrNewInstance(BRANCH, BranchConfig::class.java) } @@ -120,6 +124,10 @@ class Config(context: Context) { return getBoolean(BROWSER_REGISTRATION, false) } + private fun getExperimentalFeaturesConfig(): ExperimentalFeaturesConfig { + return getObjectOrNewInstance(EXPERIMENTAL_FEATURES, ExperimentalFeaturesConfig::class.java) + } + private fun getString(key: String, defaultValue: String = ""): String { val element = getObject(key) return if (element != null) { @@ -179,6 +187,7 @@ class Config(context: Context) { private const val DISCOVERY = "DISCOVERY" private const val PROGRAM = "PROGRAM" private const val DASHBOARD = "DASHBOARD" + private const val EXPERIMENTAL_FEATURES = "EXPERIMENTAL_FEATURES" private const val BRANCH = "BRANCH" private const val UI_COMPONENTS = "UI_COMPONENTS" private const val PLATFORM_NAME = "PLATFORM_NAME" diff --git a/core/src/main/java/org/openedx/core/config/ExperimentalFeaturesConfig.kt b/core/src/main/java/org/openedx/core/config/ExperimentalFeaturesConfig.kt new file mode 100644 index 000000000..74624178c --- /dev/null +++ b/core/src/main/java/org/openedx/core/config/ExperimentalFeaturesConfig.kt @@ -0,0 +1,8 @@ +package org.openedx.core.config + +import com.google.gson.annotations.SerializedName + +data class ExperimentalFeaturesConfig( + @SerializedName("APP_LEVEL_DOWNLOADS") + val appLevelDownloadsConfig: AppLevelDownloadsConfig, +) 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 8b5f0913a..50cd81d6b 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 @@ -9,6 +9,7 @@ import org.openedx.core.data.model.CourseDatesBannerInfo import org.openedx.core.data.model.CourseEnrollmentDetails import org.openedx.core.data.model.CourseEnrollments import org.openedx.core.data.model.CourseStructureModel +import org.openedx.core.data.model.DownloadCoursePreview import org.openedx.core.data.model.EnrollmentStatus import org.openedx.core.data.model.HandoutsModel import org.openedx.core.data.model.ResetCourseDates @@ -100,4 +101,9 @@ interface CourseApi { suspend fun getEnrollmentDetails( @Path("course_id") courseId: String, ): CourseEnrollmentDetails + + @GET("/api/mobile/v1/download_courses/{username}") + suspend fun getDownloadCoursesPreview( + @Path("username") username: String + ): List } diff --git a/core/src/main/java/org/openedx/core/data/model/DownloadCoursePreview.kt b/core/src/main/java/org/openedx/core/data/model/DownloadCoursePreview.kt new file mode 100644 index 000000000..2731b8b5d --- /dev/null +++ b/core/src/main/java/org/openedx/core/data/model/DownloadCoursePreview.kt @@ -0,0 +1,34 @@ +package org.openedx.core.data.model + +import com.google.gson.annotations.SerializedName +import org.openedx.core.data.model.room.DownloadCoursePreview as EntityDownloadCoursePreview +import org.openedx.core.domain.model.DownloadCoursePreview as DomainDownloadCoursePreview + +data class DownloadCoursePreview( + @SerializedName("course_id") + val id: String, + @SerializedName("course_name") + val name: String?, + @SerializedName("course_image") + val image: String?, + @SerializedName("total_size") + val totalSize: Long?, +) { + fun mapToDomain(): DomainDownloadCoursePreview { + return DomainDownloadCoursePreview( + id = id, + name = name ?: "", + image = image ?: "", + totalSize = totalSize ?: 0, + ) + } + + fun mapToRoomEntity(): EntityDownloadCoursePreview { + return EntityDownloadCoursePreview( + id = id, + name = name, + image = image, + totalSize = totalSize, + ) + } +} diff --git a/core/src/main/java/org/openedx/core/data/model/room/DownloadCoursePreview.kt b/core/src/main/java/org/openedx/core/data/model/room/DownloadCoursePreview.kt new file mode 100644 index 000000000..b4806f0f3 --- /dev/null +++ b/core/src/main/java/org/openedx/core/data/model/room/DownloadCoursePreview.kt @@ -0,0 +1,28 @@ +package org.openedx.core.data.model.room + +import androidx.room.ColumnInfo +import androidx.room.Entity +import androidx.room.PrimaryKey +import org.openedx.core.domain.model.DownloadCoursePreview as DomainDownloadCoursePreview + +@Entity(tableName = "download_course_preview_table") +data class DownloadCoursePreview( + @PrimaryKey + @ColumnInfo("course_id") + val id: String, + @ColumnInfo("course_name") + val name: String?, + @ColumnInfo("course_image") + val image: String?, + @ColumnInfo("total_size") + val totalSize: Long?, +) { + fun mapToDomain(): DomainDownloadCoursePreview { + return DomainDownloadCoursePreview( + id = id, + name = name ?: "", + image = image ?: "", + totalSize = totalSize ?: 0, + ) + } +} diff --git a/course/src/main/java/org/openedx/course/data/storage/CourseDao.kt b/core/src/main/java/org/openedx/core/data/storage/CourseDao.kt similarity index 96% rename from course/src/main/java/org/openedx/course/data/storage/CourseDao.kt rename to core/src/main/java/org/openedx/core/data/storage/CourseDao.kt index 8c2d94f03..1ce813242 100644 --- a/course/src/main/java/org/openedx/course/data/storage/CourseDao.kt +++ b/core/src/main/java/org/openedx/core/data/storage/CourseDao.kt @@ -1,4 +1,4 @@ -package org.openedx.course.data.storage +package org.openedx.core.data.storage import androidx.room.Dao import androidx.room.Insert diff --git a/core/src/main/java/org/openedx/core/domain/interactor/CourseInteractor.kt b/core/src/main/java/org/openedx/core/domain/interactor/CourseInteractor.kt new file mode 100644 index 000000000..ef5a8b7c5 --- /dev/null +++ b/core/src/main/java/org/openedx/core/domain/interactor/CourseInteractor.kt @@ -0,0 +1,15 @@ +package org.openedx.core.domain.interactor + +import org.openedx.core.domain.model.CourseStructure +import org.openedx.core.module.db.DownloadModel + +interface CourseInteractor { + suspend fun getCourseStructure( + courseId: String, + isNeedRefresh: Boolean = false + ): CourseStructure + + suspend fun getCourseStructureFromCache(courseId: String): CourseStructure + + suspend fun getAllDownloadModels(): List +} 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 ba7b91a41..d2c36a0f3 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 @@ -81,6 +81,14 @@ data class Block( return count } + fun getFileSize(): Long { + return when { + type == BlockType.VIDEO -> downloadModel?.size ?: 0L + isxBlock -> offlineDownload?.fileSize ?: 0L + else -> 0L + } + } + val isVideoBlock get() = type == BlockType.VIDEO val isDiscussionBlock get() = type == BlockType.DISCUSSION val isHTMLBlock get() = type == BlockType.HTML diff --git a/core/src/main/java/org/openedx/core/domain/model/DownloadCoursePreview.kt b/core/src/main/java/org/openedx/core/domain/model/DownloadCoursePreview.kt new file mode 100644 index 000000000..d4fccf4e0 --- /dev/null +++ b/core/src/main/java/org/openedx/core/domain/model/DownloadCoursePreview.kt @@ -0,0 +1,8 @@ +package org.openedx.core.domain.model + +data class DownloadCoursePreview( + val id: String, + val name: String, + val image: String, + val totalSize: Long, +) diff --git a/course/src/main/java/org/openedx/course/domain/model/DownloadDialogResource.kt b/core/src/main/java/org/openedx/core/domain/model/DownloadDialogResource.kt similarity index 81% rename from course/src/main/java/org/openedx/course/domain/model/DownloadDialogResource.kt rename to core/src/main/java/org/openedx/core/domain/model/DownloadDialogResource.kt index cded4944a..a0666f2b1 100644 --- a/course/src/main/java/org/openedx/course/domain/model/DownloadDialogResource.kt +++ b/core/src/main/java/org/openedx/core/domain/model/DownloadDialogResource.kt @@ -1,4 +1,4 @@ -package org.openedx.course.domain.model +package org.openedx.core.domain.model import androidx.compose.ui.graphics.painter.Painter 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 afb2f6383..f91a19c6a 100644 --- a/core/src/main/java/org/openedx/core/module/DownloadWorker.kt +++ b/core/src/main/java/org/openedx/core/module/DownloadWorker.kt @@ -23,6 +23,9 @@ import org.openedx.core.module.download.AbstractDownloader.DownloadResult import org.openedx.core.module.download.CurrentProgress import org.openedx.core.module.download.DownloadHelper import org.openedx.core.module.download.FileDownloader +import org.openedx.core.presentation.DownloadsAnalytics +import org.openedx.core.presentation.DownloadsAnalyticsEvent +import org.openedx.core.presentation.DownloadsAnalyticsKey import org.openedx.core.system.notifier.DownloadFailed import org.openedx.core.system.notifier.DownloadNotifier import org.openedx.core.system.notifier.DownloadProgressChanged @@ -33,12 +36,14 @@ class DownloadWorker( parameters: WorkerParameters, ) : CoroutineWorker(context, parameters), CoroutineScope { - private val notificationManager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager + private val notificationManager = + context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager private val notificationBuilder = NotificationCompat.Builder(context, CHANNEL_ID) private val notifier by inject(DownloadNotifier::class.java) private val downloadDao: DownloadDao by inject(DownloadDao::class.java) private val downloadHelper: DownloadHelper by inject(DownloadHelper::class.java) + private val analytics: DownloadsAnalytics by inject(DownloadsAnalytics::class.java) private var downloadEnqueue = listOf() private var downloadError = mutableListOf() @@ -134,9 +139,11 @@ class DownloadWorker( ) ) ) + logEvent(DownloadsAnalyticsEvent.DOWNLOAD_STARTED) val downloadResult = fileDownloader.download(downloadTask.url, downloadTask.path) when (downloadResult) { DownloadResult.SUCCESS -> { + logEvent(DownloadsAnalyticsEvent.DOWNLOAD_COMPLETED) val updatedModel = downloadHelper.updateDownloadStatus(downloadTask) if (updatedModel == null) { downloadDao.removeDownloadModel(downloadTask.id) @@ -149,10 +156,12 @@ class DownloadWorker( } DownloadResult.CANCELED -> { + logEvent(DownloadsAnalyticsEvent.DOWNLOAD_CANCELLED) downloadDao.removeDownloadModel(downloadTask.id) } DownloadResult.ERROR -> { + logEvent(DownloadsAnalyticsEvent.DOWNLOAD_ERROR) downloadDao.removeDownloadModel(downloadTask.id) downloadError.add(downloadTask) } @@ -173,6 +182,15 @@ class DownloadWorker( notificationManager.createNotificationChannel(notificationChannel) } + fun logEvent(event: DownloadsAnalyticsEvent) { + analytics.logEvent( + event = event.eventName, + params = buildMap { + put(DownloadsAnalyticsKey.NAME.key, event.biValue) + } + ) + } + companion object { const val WORKER_TAG = "downloadWorker" diff --git a/core/src/main/java/org/openedx/core/module/db/DownloadDao.kt b/core/src/main/java/org/openedx/core/module/db/DownloadDao.kt index a07329e4d..377a8a2d9 100644 --- a/core/src/main/java/org/openedx/core/module/db/DownloadDao.kt +++ b/core/src/main/java/org/openedx/core/module/db/DownloadDao.kt @@ -6,6 +6,7 @@ import androidx.room.OnConflictStrategy import androidx.room.Query import androidx.room.Update import kotlinx.coroutines.flow.Flow +import org.openedx.core.data.model.room.DownloadCoursePreview import org.openedx.core.data.model.room.OfflineXBlockProgress @Dao @@ -32,6 +33,9 @@ interface DownloadDao { @Query("DELETE FROM download_model WHERE id in (:ids)") suspend fun removeAllDownloadModels(ids: List) + @Query("SELECT * FROM download_model WHERE courseId = :courseId") + suspend fun getDownloadModelsByCourseIds(courseId: String): List + @Insert(onConflict = OnConflictStrategy.REPLACE) suspend fun insertOfflineXBlockProgress(offlineXBlockProgress: OfflineXBlockProgress) @@ -46,4 +50,10 @@ interface DownloadDao { @Query("DELETE FROM offline_x_block_progress_table") suspend fun clearOfflineProgress() + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun insertDownloadCoursePreview(downloadCoursePreview: List) + + @Query("SELECT * FROM download_course_preview_table") + fun getDownloadCoursesPreview(): List } diff --git a/core/src/main/java/org/openedx/core/module/db/DownloadModel.kt b/core/src/main/java/org/openedx/core/module/db/DownloadModel.kt index da736ba28..9f5abd3f4 100644 --- a/core/src/main/java/org/openedx/core/module/db/DownloadModel.kt +++ b/core/src/main/java/org/openedx/core/module/db/DownloadModel.kt @@ -17,11 +17,11 @@ data class DownloadModel( ) : Parcelable enum class DownloadedState { - WAITING, DOWNLOADING, DOWNLOADED, NOT_DOWNLOADED; + WAITING, DOWNLOADING, DOWNLOADED, NOT_DOWNLOADED, LOADING_COURSE_STRUCTURE; val isWaitingOrDownloading: Boolean get() { - return this == WAITING || this == DOWNLOADING + return this == WAITING || this == DOWNLOADING || this == LOADING_COURSE_STRUCTURE } val isDownloaded: Boolean diff --git a/core/src/main/java/org/openedx/core/module/download/BaseDownloadViewModel.kt b/core/src/main/java/org/openedx/core/module/download/BaseDownloadViewModel.kt index 0fcf962a3..1f4de150a 100644 --- a/core/src/main/java/org/openedx/core/module/download/BaseDownloadViewModel.kt +++ b/core/src/main/java/org/openedx/core/module/download/BaseDownloadViewModel.kt @@ -19,7 +19,6 @@ import org.openedx.core.presentation.CoreAnalyticsKey import org.openedx.foundation.presentation.BaseViewModel abstract class BaseDownloadViewModel( - private val courseId: String, private val downloadDao: DownloadDao, private val preferencesManager: CorePreferences, private val workerController: DownloadWorkerController, @@ -66,8 +65,7 @@ abstract class BaseDownloadViewModel( updateParentStatus(parentId, children.size, downloadingCount, downloadedCount) } - downloadingModelsList = models.filter { it.downloadedState.isWaitingOrDownloading } - _downloadingModelsFlow.emit(downloadingModelsList) + _downloadingModelsFlow.emit(models) } private fun updateChildrenStatus( @@ -116,6 +114,10 @@ abstract class BaseDownloadViewModel( allBlocks.putAll(list.map { it.id to it }) } + protected fun addBlocks(list: List) { + allBlocks.putAll(list.map { it.id to it }) + } + fun isBlockDownloading(id: String): Boolean { val blockDownloadingState = downloadModelsStatus[id] return blockDownloadingState?.isWaitingOrDownloading == true @@ -126,22 +128,22 @@ abstract class BaseDownloadViewModel( return blockDownloadingState == DownloadedState.DOWNLOADED } - open fun saveDownloadModels(folder: String, id: String) { + open fun saveDownloadModels(folder: String, courseId: String, id: String) { viewModelScope.launch { val saveBlocksIds = downloadableChildrenMap[id] ?: listOf() - logSubsectionDownloadEvent(id, saveBlocksIds.size) - saveDownloadModels(folder, saveBlocksIds) + logSubsectionDownloadEvent(id, saveBlocksIds.size, courseId) + saveDownloadModels(folder, courseId, saveBlocksIds) } } - open fun saveAllDownloadModels(folder: String) { + open fun saveAllDownloadModels(folder: String, courseId: String) { viewModelScope.launch { val saveBlocksIds = downloadableChildrenMap.values.flatten() - saveDownloadModels(folder, saveBlocksIds) + saveDownloadModels(folder, courseId, saveBlocksIds) } } - suspend fun saveDownloadModels(folder: String, saveBlocksIds: List) { + suspend fun saveDownloadModels(folder: String, courseId: String, saveBlocksIds: List) { val downloadModels = mutableListOf() val downloadModelList = getDownloadModelList() for (blockId in saveBlocksIds) { @@ -200,10 +202,10 @@ abstract class BaseDownloadViewModel( fun getDownloadableChildren(id: String) = downloadableChildrenMap[id] - open fun removeDownloadModels(blockId: String) { + open fun removeDownloadModels(blockId: String, courseId: String) { viewModelScope.launch { val downloadableChildren = downloadableChildrenMap[blockId] ?: listOf() - logSubsectionDeleteEvent(blockId, downloadableChildren.size) + logSubsectionDeleteEvent(blockId, downloadableChildren.size, courseId) workerController.removeModels(downloadableChildren) } } @@ -242,36 +244,51 @@ abstract class BaseDownloadViewModel( downloadableChildrenMap[parentId] = children + childId } - fun logBulkDownloadToggleEvent(toggle: Boolean) { + fun logBulkDownloadToggleEvent(toggle: Boolean, courseId: String) { logEvent( CoreAnalyticsEvent.VIDEO_BULK_DOWNLOAD_TOGGLE, buildMap { put(CoreAnalyticsKey.ACTION.key, toggle) - } + }, + courseId ) } - private fun logSubsectionDownloadEvent(subsectionId: String, numberOfVideos: Int) { + private fun logSubsectionDownloadEvent( + subsectionId: String, + numberOfVideos: Int, + courseId: String + ) { logEvent( CoreAnalyticsEvent.VIDEO_DOWNLOAD_SUBSECTION, buildMap { put(CoreAnalyticsKey.BLOCK_ID.key, subsectionId) put(CoreAnalyticsKey.NUMBER_OF_VIDEOS.key, numberOfVideos) - } + }, + courseId ) } - private fun logSubsectionDeleteEvent(subsectionId: String, numberOfVideos: Int) { + private fun logSubsectionDeleteEvent( + subsectionId: String, + numberOfVideos: Int, + courseId: String + ) { logEvent( CoreAnalyticsEvent.VIDEO_DELETE_SUBSECTION, buildMap { put(CoreAnalyticsKey.BLOCK_ID.key, subsectionId) put(CoreAnalyticsKey.NUMBER_OF_VIDEOS.key, numberOfVideos) - } + }, + courseId ) } - private fun logEvent(event: CoreAnalyticsEvent, param: Map = emptyMap()) { + private fun logEvent( + event: CoreAnalyticsEvent, + param: Map = emptyMap(), + courseId: String + ) { analytics.logEvent( event.eventName, buildMap { diff --git a/core/src/main/java/org/openedx/core/presentation/DownloadsAnalytics.kt b/core/src/main/java/org/openedx/core/presentation/DownloadsAnalytics.kt new file mode 100644 index 000000000..625140d4f --- /dev/null +++ b/core/src/main/java/org/openedx/core/presentation/DownloadsAnalytics.kt @@ -0,0 +1,49 @@ +package org.openedx.core.presentation + +interface DownloadsAnalytics { + fun logEvent(event: String, params: Map) + fun logScreenEvent(screenName: String, params: Map) +} + +enum class DownloadsAnalyticsEvent(val eventName: String, val biValue: String) { + DOWNLOAD_COURSE_CLICKED( + "Downloads:Download Course Clicked", + "edx.bi.app.downloads.downloadCourseClicked" + ), + CANCEL_DOWNLOAD_CLICKED( + "Downloads:Cancel Download Clicked", + "edx.bi.app.downloads.cancelDownloadClicked" + ), + REMOVE_DOWNLOAD_CLICKED( + "Downloads:Remove Download Clicked", + "edx.bi.app.downloads.removeDownloadClicked" + ), + DOWNLOAD_CONFIRMED( + "Downloads:Download Confirmed", + "edx.bi.app.downloads.downloadConfirmed" + ), + DOWNLOAD_CANCELLED( + "Downloads:Download Cancelled", + "edx.bi.app.downloads.downloadCancelled" + ), + DOWNLOAD_REMOVED( + "Downloads:Download Removed", + "edx.bi.app.downloads.downloadRemoved" + ), + DOWNLOAD_ERROR( + "Downloads:Download Error", + "edx.bi.app.downloads.downloadError" + ), + DOWNLOAD_COMPLETED( + "Downloads:Download Completed", + "edx.bi.app.downloads.downloadCompleted" + ), + DOWNLOAD_STARTED( + "Downloads:Download Started", + "edx.bi.app.downloads.downloadStarted" + ), +} + +enum class DownloadsAnalyticsKey(val key: String) { + NAME("name"), +} diff --git a/course/src/main/java/org/openedx/course/presentation/download/DownloadConfirmDialogFragment.kt b/core/src/main/java/org/openedx/core/presentation/dialog/downloaddialog/DownloadConfirmDialogFragment.kt similarity index 85% rename from course/src/main/java/org/openedx/course/presentation/download/DownloadConfirmDialogFragment.kt rename to core/src/main/java/org/openedx/core/presentation/dialog/downloaddialog/DownloadConfirmDialogFragment.kt index c591966f4..5ab8db529 100644 --- a/course/src/main/java/org/openedx/course/presentation/download/DownloadConfirmDialogFragment.kt +++ b/core/src/main/java/org/openedx/core/presentation/dialog/downloaddialog/DownloadConfirmDialogFragment.kt @@ -1,7 +1,6 @@ -package org.openedx.course.presentation.download +package org.openedx.core.presentation.dialog.downloaddialog import android.graphics.Color -import android.graphics.drawable.ColorDrawable import android.os.Bundle import android.view.LayoutInflater import android.view.ViewGroup @@ -11,6 +10,7 @@ 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.heightIn import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width @@ -31,8 +31,11 @@ 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.core.graphics.drawable.toDrawable import androidx.core.os.bundleOf import androidx.fragment.app.DialogFragment +import org.openedx.core.R +import org.openedx.core.domain.model.DownloadDialogResource import org.openedx.core.presentation.dialog.DefaultDialogBox import org.openedx.core.ui.AutoSizeText import org.openedx.core.ui.IconText @@ -41,51 +44,52 @@ import org.openedx.core.ui.OpenEdXOutlinedButton import org.openedx.core.ui.theme.OpenEdXTheme import org.openedx.core.ui.theme.appColors import org.openedx.core.ui.theme.appTypography -import org.openedx.course.R -import org.openedx.course.domain.model.DownloadDialogResource import org.openedx.foundation.extension.parcelable import org.openedx.foundation.extension.toFileSize import org.openedx.foundation.system.PreviewFragmentManager import androidx.compose.ui.graphics.Color as ComposeColor -import org.openedx.core.R as coreR -class DownloadConfirmDialogFragment : DialogFragment() { +class DownloadConfirmDialogFragment : DialogFragment(), DownloadDialog { + + override var listener: DownloadDialogListener? = null override fun onCreateView( inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?, ) = ComposeView(requireContext()).apply { - dialog?.window?.setBackgroundDrawable(ColorDrawable(Color.TRANSPARENT)) + dialog?.window?.setBackgroundDrawable(Color.TRANSPARENT.toDrawable()) setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed) setContent { OpenEdXTheme { val dialogType = - requireArguments().parcelable(ARG_DIALOG_TYPE) ?: return@OpenEdXTheme - val uiState = requireArguments().parcelable(ARG_UI_STATE) ?: return@OpenEdXTheme + requireArguments().parcelable(ARG_DIALOG_TYPE) + ?: return@OpenEdXTheme + val uiState = requireArguments().parcelable(ARG_UI_STATE) + ?: return@OpenEdXTheme val sizeSumString = uiState.sizeSum.toFileSize(1, false) val dialogData = when (dialogType) { DownloadConfirmDialogType.CONFIRM -> DownloadDialogResource( - title = stringResource(id = coreR.string.course_confirm_download), + title = stringResource(id = R.string.course_confirm_download), description = stringResource( - id = R.string.course_download_confirm_dialog_description, + id = R.string.core_download_confirm_dialog_description, sizeSumString ), ) DownloadConfirmDialogType.DOWNLOAD_ON_CELLULAR -> DownloadDialogResource( - title = stringResource(id = R.string.course_download_on_cellural), + title = stringResource(id = R.string.core_download_on_cellural), description = stringResource( - id = R.string.course_download_on_cellural_dialog_description, + id = R.string.core_download_on_cellural_dialog_description, sizeSumString ), - icon = painterResource(id = coreR.drawable.core_ic_warning), + icon = painterResource(id = R.drawable.core_ic_warning), ) DownloadConfirmDialogType.REMOVE -> DownloadDialogResource( - title = stringResource(id = R.string.course_download_remove_offline_content), + title = stringResource(id = R.string.core_download_remove_offline_content), description = stringResource( - id = R.string.course_download_remove_dialog_description, + id = R.string.core_download_remove_dialog_description, sizeSumString ) ) @@ -98,6 +102,7 @@ class DownloadConfirmDialogFragment : DialogFragment() { onConfirmClick = { uiState.saveDownloadModels() dismiss() + listener?.onConfirmClick() }, onRemoveClick = { uiState.removeDownloadModels() @@ -105,6 +110,7 @@ class DownloadConfirmDialogFragment : DialogFragment() { }, onCancelClick = { dismiss() + listener?.onCancelClick() } ) } @@ -112,7 +118,6 @@ class DownloadConfirmDialogFragment : DialogFragment() { } companion object { - const val DIALOG_TAG = "DownloadConfirmDialogFragment" const val ARG_DIALOG_TYPE = "dialogType" const val ARG_UI_STATE = "uiState" @@ -148,7 +153,6 @@ private fun DownloadConfirmDialogView( Column( modifier = Modifier .fillMaxWidth() - .verticalScroll(scrollState) .padding(20.dp), verticalArrangement = Arrangement.spacedBy(12.dp) ) { @@ -171,7 +175,11 @@ private fun DownloadConfirmDialogView( minSize = MaterialTheme.appTypography.titleLarge.fontSize.value - 1 ) } - Column { + Column( + modifier = Modifier + .heightIn(max = DownloadDialogManager.listMaxSize) + .verticalScroll(scrollState) + ) { uiState.downloadDialogItems.forEach { DownloadDialogItem(downloadDialogItem = it) } @@ -188,14 +196,14 @@ private fun DownloadConfirmDialogView( val onClick: () -> Unit when (dialogType) { DownloadConfirmDialogType.REMOVE -> { - buttonText = stringResource(id = R.string.course_remove) + buttonText = stringResource(id = R.string.core_remove) buttonIcon = Icons.Rounded.Delete buttonColor = MaterialTheme.appColors.error onClick = onRemoveClick } else -> { - buttonText = stringResource(id = R.string.course_download) + buttonText = stringResource(id = R.string.core_download) buttonIcon = Icons.Outlined.CloudDownload buttonColor = MaterialTheme.appColors.secondaryButtonBackground onClick = onConfirmClick @@ -216,7 +224,7 @@ private fun DownloadConfirmDialogView( ) OpenEdXOutlinedButton( modifier = Modifier.fillMaxWidth(), - text = stringResource(id = coreR.string.core_cancel), + text = stringResource(id = R.string.core_cancel), backgroundColor = MaterialTheme.appColors.background, borderColor = MaterialTheme.appColors.primaryButtonBackground, textColor = MaterialTheme.appColors.primaryButtonBackground, diff --git a/course/src/main/java/org/openedx/course/presentation/download/DownloadConfirmDialogType.kt b/core/src/main/java/org/openedx/core/presentation/dialog/downloaddialog/DownloadConfirmDialogType.kt similarity index 74% rename from course/src/main/java/org/openedx/course/presentation/download/DownloadConfirmDialogType.kt rename to core/src/main/java/org/openedx/core/presentation/dialog/downloaddialog/DownloadConfirmDialogType.kt index 9c0833ff3..a14a1033c 100644 --- a/course/src/main/java/org/openedx/course/presentation/download/DownloadConfirmDialogType.kt +++ b/core/src/main/java/org/openedx/core/presentation/dialog/downloaddialog/DownloadConfirmDialogType.kt @@ -1,4 +1,4 @@ -package org.openedx.course.presentation.download +package org.openedx.core.presentation.dialog.downloaddialog import android.os.Parcelable import kotlinx.parcelize.Parcelize diff --git a/course/src/main/java/org/openedx/course/presentation/download/DownloadDialogItem.kt b/core/src/main/java/org/openedx/core/presentation/dialog/downloaddialog/DownloadDialogItem.kt similarity index 83% rename from course/src/main/java/org/openedx/course/presentation/download/DownloadDialogItem.kt rename to core/src/main/java/org/openedx/core/presentation/dialog/downloaddialog/DownloadDialogItem.kt index 9f3cfc4d4..2e29ccec4 100644 --- a/course/src/main/java/org/openedx/course/presentation/download/DownloadDialogItem.kt +++ b/core/src/main/java/org/openedx/core/presentation/dialog/downloaddialog/DownloadDialogItem.kt @@ -1,4 +1,4 @@ -package org.openedx.course.presentation.download +package org.openedx.core.presentation.dialog.downloaddialog import android.os.Parcelable import androidx.compose.ui.graphics.vector.ImageVector diff --git a/course/src/main/java/org/openedx/course/presentation/download/DownloadDialogManager.kt b/core/src/main/java/org/openedx/core/presentation/dialog/downloaddialog/DownloadDialogManager.kt similarity index 57% rename from course/src/main/java/org/openedx/course/presentation/download/DownloadDialogManager.kt rename to core/src/main/java/org/openedx/core/presentation/dialog/downloaddialog/DownloadDialogManager.kt index 434f74c67..cc9959c79 100644 --- a/course/src/main/java/org/openedx/course/presentation/download/DownloadDialogManager.kt +++ b/core/src/main/java/org/openedx/core/presentation/dialog/downloaddialog/DownloadDialogManager.kt @@ -1,5 +1,12 @@ -package org.openedx.course.presentation.download +package org.openedx.core.presentation.dialog.downloaddialog +import android.content.res.Configuration +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.School +import androidx.compose.runtime.Composable +import androidx.compose.ui.platform.LocalConfiguration +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp import androidx.fragment.app.FragmentManager import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers @@ -7,12 +14,23 @@ import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.launch import org.openedx.core.BlockType import org.openedx.core.data.storage.CorePreferences +import org.openedx.core.domain.interactor.CourseInteractor import org.openedx.core.domain.model.Block +import org.openedx.core.domain.model.DownloadCoursePreview import org.openedx.core.module.DownloadWorkerController import org.openedx.core.module.db.DownloadModel import org.openedx.core.system.StorageManager import org.openedx.core.system.connection.NetworkConnection -import org.openedx.course.domain.interactor.CourseInteractor +import org.openedx.foundation.presentation.rememberWindowSize + +interface DownloadDialogListener { + fun onCancelClick() + fun onConfirmClick() +} + +interface DownloadDialog { + var listener: DownloadDialogListener? +} class DownloadDialogManager( private val networkConnection: NetworkConnection, @@ -24,6 +42,22 @@ class DownloadDialogManager( companion object { const val MAX_CELLULAR_SIZE = 104857600 // 100MB const val DOWNLOAD_SIZE_FACTOR = 2 // Multiplier to match required disk size + + val listMaxSize: Dp + @Composable + get() { + val configuration = LocalConfiguration.current + val windowSize = rememberWindowSize() + return when { + configuration.orientation == Configuration.ORIENTATION_PORTRAIT || windowSize.isTablet -> { + 200.dp + } + + else -> { + 88.dp + } + } + } } private val uiState = MutableSharedFlow() @@ -76,7 +110,22 @@ class DownloadDialogManager( else -> null } - dialog?.show(state.fragmentManager, dialog::class.java.simpleName) ?: state.saveDownloadModels() + val dialogListener = object : DownloadDialogListener { + override fun onCancelClick() { + state.onDismissClick() + } + + override fun onConfirmClick() { + state.onConfirmClick() + } + } + if (dialog != null) { + dialog.listener = dialogListener + dialog.show(state.fragmentManager, dialog::class.java.simpleName) + } else { + state.onConfirmClick() + state.saveDownloadModels() + } } } } @@ -87,8 +136,10 @@ class DownloadDialogManager( isBlocksDownloaded: Boolean, onlyVideoBlocks: Boolean = false, fragmentManager: FragmentManager, - removeDownloadModels: (blockId: String) -> Unit, + removeDownloadModels: (blockId: String, courseId: String) -> Unit, saveDownloadModels: (blockId: String) -> Unit, + onDismissClick: () -> Unit = {}, + onConfirmClick: () -> Unit = {}, ) { createDownloadItems( subSectionsBlocks = subSectionsBlocks, @@ -97,7 +148,29 @@ class DownloadDialogManager( isBlocksDownloaded = isBlocksDownloaded, onlyVideoBlocks = onlyVideoBlocks, removeDownloadModels = removeDownloadModels, - saveDownloadModels = saveDownloadModels + saveDownloadModels = saveDownloadModels, + onDismissClick = onDismissClick, + onConfirmClick = onConfirmClick + ) + } + + fun showPopup( + coursePreview: DownloadCoursePreview, + isBlocksDownloaded: Boolean, + fragmentManager: FragmentManager, + removeDownloadModels: (blockId: String, courseId: String) -> Unit, + saveDownloadModels: () -> Unit, + onDismissClick: () -> Unit = {}, + onConfirmClick: () -> Unit = {}, + ) { + createCourseDownloadItems( + coursePreview = coursePreview, + fragmentManager = fragmentManager, + isBlocksDownloaded = isBlocksDownloaded, + removeDownloadModels = removeDownloadModels, + saveDownloadModels = saveDownloadModels, + onDismissClick = onDismissClick, + onConfirmClick = onConfirmClick ) } @@ -143,14 +216,16 @@ class DownloadDialogManager( courseIds.forEach { courseId -> val courseStructure = interactor.getCourseStructureFromCache(courseId) - val allSubSectionBlocks = courseStructure.blockData.filter { it.type == BlockType.SEQUENTIAL } + val allSubSectionBlocks = + courseStructure.blockData.filter { it.type == BlockType.SEQUENTIAL } allSubSectionBlocks.forEach { subSectionBlock -> - val verticalBlocks = courseStructure.blockData.filter { it.id in subSectionBlock.descendants } + val verticalBlocks = + courseStructure.blockData.filter { it.id in subSectionBlock.descendants } val blocks = courseStructure.blockData.filter { it.id in verticalBlocks.flatMap { it.descendants } && it.id in blockIds } - val totalSize = blocks.sumOf { getFileSize(it) } + val totalSize = blocks.sumOf { it.getFileSize() } if (blocks.isNotEmpty()) notDownloadedSubSections.add(subSectionBlock) if (totalSize > 0) { @@ -188,15 +263,18 @@ class DownloadDialogManager( fragmentManager: FragmentManager, isBlocksDownloaded: Boolean, onlyVideoBlocks: Boolean, - removeDownloadModels: (blockId: String) -> Unit, + removeDownloadModels: (blockId: String, courseId: String) -> Unit, saveDownloadModels: (blockId: String) -> Unit, + onDismissClick: () -> Unit = {}, + onConfirmClick: () -> Unit = {}, ) { coroutineScope.launch { val courseStructure = interactor.getCourseStructure(courseId, false) val downloadModelIds = interactor.getAllDownloadModels().map { it.id } val downloadDialogItems = subSectionsBlocks.mapNotNull { subSectionBlock -> - val verticalBlocks = courseStructure.blockData.filter { it.id in subSectionBlock.descendants } + val verticalBlocks = + courseStructure.blockData.filter { it.id in subSectionBlock.descendants } val blocks = verticalBlocks.flatMap { verticalBlock -> courseStructure.blockData.filter { it.id in verticalBlock.descendants && @@ -204,8 +282,15 @@ class DownloadDialogManager( (!onlyVideoBlocks || it.type == BlockType.VIDEO) } } - val size = blocks.sumOf { getFileSize(it) } - if (size > 0) DownloadDialogItem(title = subSectionBlock.displayName, size = size) else null + val size = blocks.sumOf { it.getFileSize() } + if (size > 0) { + DownloadDialogItem( + title = subSectionBlock.displayName, + size = size + ) + } else { + null + } } uiState.emit( @@ -215,18 +300,65 @@ class DownloadDialogManager( isDownloadFailed = false, sizeSum = downloadDialogItems.sumOf { it.size }, fragmentManager = fragmentManager, - removeDownloadModels = { subSectionsBlocks.forEach { removeDownloadModels(it.id) } }, - saveDownloadModels = { subSectionsBlocks.forEach { saveDownloadModels(it.id) } } + removeDownloadModels = { + subSectionsBlocks.forEach { + removeDownloadModels( + it.id, + courseId + ) + } + }, + saveDownloadModels = { subSectionsBlocks.forEach { saveDownloadModels(it.id) } }, + onDismissClick = onDismissClick, + onConfirmClick = onConfirmClick, ) ) } } - private fun getFileSize(block: Block): Long { - return when { - block.type == BlockType.VIDEO -> block.downloadModel?.size ?: 0L - block.isxBlock -> block.offlineDownload?.fileSize ?: 0L - else -> 0L + private fun createCourseDownloadItems( + coursePreview: DownloadCoursePreview, + fragmentManager: FragmentManager, + isBlocksDownloaded: Boolean, + removeDownloadModels: (blockId: String, courseId: String) -> Unit, + saveDownloadModels: () -> Unit, + onDismissClick: () -> Unit = {}, + onConfirmClick: () -> Unit = {}, + ) { + coroutineScope.launch { + val downloadDialogItems = listOf( + DownloadDialogItem( + title = coursePreview.name, + size = coursePreview.totalSize, + icon = Icons.Default.School + ) + ) + + uiState.emit( + DownloadDialogUIState( + downloadDialogItems = downloadDialogItems, + isAllBlocksDownloaded = isBlocksDownloaded, + isDownloadFailed = false, + sizeSum = downloadDialogItems.sumOf { it.size }, + fragmentManager = fragmentManager, + removeDownloadModels = { + coroutineScope.launch { + val downloadModels = interactor.getAllDownloadModels().filter { + it.courseId == coursePreview.id + } + downloadModels.forEach { + removeDownloadModels( + it.id, + coursePreview.id + ) + } + } + }, + saveDownloadModels = saveDownloadModels, + onDismissClick = onDismissClick, + onConfirmClick = onConfirmClick, + ) + ) } } } diff --git a/course/src/main/java/org/openedx/course/presentation/download/DownloadDialogUIState.kt b/core/src/main/java/org/openedx/core/presentation/dialog/downloaddialog/DownloadDialogUIState.kt similarity index 71% rename from course/src/main/java/org/openedx/course/presentation/download/DownloadDialogUIState.kt rename to core/src/main/java/org/openedx/core/presentation/dialog/downloaddialog/DownloadDialogUIState.kt index b58e856bd..72288449b 100644 --- a/course/src/main/java/org/openedx/course/presentation/download/DownloadDialogUIState.kt +++ b/core/src/main/java/org/openedx/core/presentation/dialog/downloaddialog/DownloadDialogUIState.kt @@ -1,4 +1,4 @@ -package org.openedx.course.presentation.download +package org.openedx.core.presentation.dialog.downloaddialog import android.os.Parcelable import androidx.fragment.app.FragmentManager @@ -13,5 +13,7 @@ data class DownloadDialogUIState( val isDownloadFailed: Boolean, val fragmentManager: @RawValue FragmentManager, val removeDownloadModels: () -> Unit, - val saveDownloadModels: () -> Unit + val saveDownloadModels: () -> Unit, + val onDismissClick: () -> Unit = {}, + val onConfirmClick: () -> Unit = {}, ) : Parcelable diff --git a/course/src/main/java/org/openedx/course/presentation/download/DownloadErrorDialogFragment.kt b/core/src/main/java/org/openedx/core/presentation/dialog/downloaddialog/DownloadErrorDialogFragment.kt similarity index 85% rename from course/src/main/java/org/openedx/course/presentation/download/DownloadErrorDialogFragment.kt rename to core/src/main/java/org/openedx/core/presentation/dialog/downloaddialog/DownloadErrorDialogFragment.kt index 96cdf3d40..f7bbe6ea5 100644 --- a/course/src/main/java/org/openedx/course/presentation/download/DownloadErrorDialogFragment.kt +++ b/core/src/main/java/org/openedx/core/presentation/dialog/downloaddialog/DownloadErrorDialogFragment.kt @@ -1,7 +1,6 @@ -package org.openedx.course.presentation.download +package org.openedx.core.presentation.dialog.downloaddialog import android.graphics.Color -import android.graphics.drawable.ColorDrawable import android.os.Bundle import android.view.LayoutInflater import android.view.ViewGroup @@ -11,6 +10,7 @@ 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.heightIn import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width @@ -27,8 +27,11 @@ 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.core.graphics.drawable.toDrawable import androidx.core.os.bundleOf import androidx.fragment.app.DialogFragment +import org.openedx.core.R +import org.openedx.core.domain.model.DownloadDialogResource import org.openedx.core.presentation.dialog.DefaultDialogBox import org.openedx.core.ui.AutoSizeText import org.openedx.core.ui.OpenEdXButton @@ -36,20 +39,19 @@ import org.openedx.core.ui.OpenEdXOutlinedButton import org.openedx.core.ui.theme.OpenEdXTheme import org.openedx.core.ui.theme.appColors import org.openedx.core.ui.theme.appTypography -import org.openedx.course.R -import org.openedx.course.domain.model.DownloadDialogResource import org.openedx.foundation.extension.parcelable import org.openedx.foundation.system.PreviewFragmentManager -import org.openedx.core.R as coreR -class DownloadErrorDialogFragment : DialogFragment() { +class DownloadErrorDialogFragment : DialogFragment(), DownloadDialog { + + override var listener: DownloadDialogListener? = null override fun onCreateView( inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?, ) = ComposeView(requireContext()).apply { - dialog?.window?.setBackgroundDrawable(ColorDrawable(Color.TRANSPARENT)) + dialog?.window?.setBackgroundDrawable(Color.TRANSPARENT.toDrawable()) setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed) setContent { OpenEdXTheme { @@ -58,21 +60,21 @@ class DownloadErrorDialogFragment : DialogFragment() { val uiState = requireArguments().parcelable(ARG_UI_STATE) ?: return@OpenEdXTheme val downloadDialogResource = when (dialogType) { DownloadErrorDialogType.NO_CONNECTION -> DownloadDialogResource( - title = stringResource(id = coreR.string.core_no_internet_connection), - description = stringResource(id = R.string.course_download_no_internet_dialog_description), - icon = painterResource(id = R.drawable.course_ic_error), + title = stringResource(id = R.string.core_no_internet_connection), + description = stringResource(id = R.string.core_download_no_internet_dialog_description), + icon = painterResource(id = R.drawable.core_ic_error), ) DownloadErrorDialogType.WIFI_REQUIRED -> DownloadDialogResource( - title = stringResource(id = R.string.course_wifi_required), - description = stringResource(id = R.string.course_download_wifi_required_dialog_description), - icon = painterResource(id = R.drawable.course_ic_error), + title = stringResource(id = R.string.core_wifi_required), + description = stringResource(id = R.string.core_download_wifi_required_dialog_description), + icon = painterResource(id = R.drawable.core_ic_error), ) DownloadErrorDialogType.DOWNLOAD_FAILED -> DownloadDialogResource( - title = stringResource(id = R.string.course_download_failed), - description = stringResource(id = R.string.course_download_failed_dialog_description), - icon = painterResource(id = R.drawable.course_ic_error), + title = stringResource(id = R.string.core_download_failed), + description = stringResource(id = R.string.core_download_failed_dialog_description), + icon = painterResource(id = R.drawable.core_ic_error), ) } @@ -86,6 +88,7 @@ class DownloadErrorDialogFragment : DialogFragment() { }, onCancelClick = { dismiss() + listener?.onCancelClick() } ) } @@ -93,7 +96,6 @@ class DownloadErrorDialogFragment : DialogFragment() { } companion object { - const val DIALOG_TAG = "DownloadErrorDialogFragment" const val ARG_DIALOG_TYPE = "dialogType" const val ARG_UI_STATE = "uiState" @@ -122,8 +124,8 @@ private fun DownloadErrorDialogView( ) { val scrollState = rememberScrollState() val dismissButtonText = when (dialogType) { - DownloadErrorDialogType.DOWNLOAD_FAILED -> stringResource(id = coreR.string.core_cancel) - else -> stringResource(id = coreR.string.core_close) + DownloadErrorDialogType.DOWNLOAD_FAILED -> stringResource(id = R.string.core_cancel) + else -> stringResource(id = R.string.core_close) } DefaultDialogBox( modifier = modifier, @@ -132,7 +134,6 @@ private fun DownloadErrorDialogView( Column( modifier = Modifier .fillMaxWidth() - .verticalScroll(scrollState) .padding(20.dp), verticalArrangement = Arrangement.spacedBy(12.dp) ) { @@ -155,7 +156,11 @@ private fun DownloadErrorDialogView( minSize = MaterialTheme.appTypography.titleLarge.fontSize.value - 1 ) } - Column { + Column( + modifier = Modifier + .heightIn(max = DownloadDialogManager.listMaxSize) + .verticalScroll(scrollState) + ) { uiState.downloadDialogItems.forEach { DownloadDialogItem(downloadDialogItem = it) } @@ -167,7 +172,7 @@ private fun DownloadErrorDialogView( ) if (dialogType == DownloadErrorDialogType.DOWNLOAD_FAILED) { OpenEdXButton( - text = stringResource(id = coreR.string.core_error_try_again), + text = stringResource(id = R.string.core_error_try_again), backgroundColor = MaterialTheme.appColors.secondaryButtonBackground, onClick = onTryAgainClick, ) @@ -194,7 +199,7 @@ private fun DownloadErrorDialogViewPreview() { downloadDialogResource = DownloadDialogResource( title = "Title", description = "Description Description Description Description Description Description Description ", - icon = painterResource(id = R.drawable.course_ic_error) + icon = painterResource(id = R.drawable.core_ic_error) ), uiState = DownloadDialogUIState( downloadDialogItems = listOf( diff --git a/course/src/main/java/org/openedx/course/presentation/download/DownloadErrorDialogType.kt b/core/src/main/java/org/openedx/core/presentation/dialog/downloaddialog/DownloadErrorDialogType.kt similarity index 74% rename from course/src/main/java/org/openedx/course/presentation/download/DownloadErrorDialogType.kt rename to core/src/main/java/org/openedx/core/presentation/dialog/downloaddialog/DownloadErrorDialogType.kt index 85f01cf1a..5bb035f07 100644 --- a/course/src/main/java/org/openedx/course/presentation/download/DownloadErrorDialogType.kt +++ b/core/src/main/java/org/openedx/core/presentation/dialog/downloaddialog/DownloadErrorDialogType.kt @@ -1,4 +1,4 @@ -package org.openedx.course.presentation.download +package org.openedx.core.presentation.dialog.downloaddialog import android.os.Parcelable import kotlinx.parcelize.Parcelize diff --git a/course/src/main/java/org/openedx/course/presentation/download/DownloadStorageErrorDialogFragment.kt b/core/src/main/java/org/openedx/core/presentation/dialog/downloaddialog/DownloadStorageErrorDialogFragment.kt similarity index 83% rename from course/src/main/java/org/openedx/course/presentation/download/DownloadStorageErrorDialogFragment.kt rename to core/src/main/java/org/openedx/core/presentation/dialog/downloaddialog/DownloadStorageErrorDialogFragment.kt index 5b99e6123..8c026bdf2 100644 --- a/course/src/main/java/org/openedx/course/presentation/download/DownloadStorageErrorDialogFragment.kt +++ b/core/src/main/java/org/openedx/core/presentation/dialog/downloaddialog/DownloadStorageErrorDialogFragment.kt @@ -1,7 +1,6 @@ -package org.openedx.course.presentation.download +package org.openedx.core.presentation.dialog.downloaddialog import android.graphics.Color -import android.graphics.drawable.ColorDrawable import android.os.Bundle import android.view.LayoutInflater import android.view.ViewGroup @@ -19,6 +18,7 @@ import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width @@ -39,40 +39,43 @@ 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.core.graphics.drawable.toDrawable import androidx.core.os.bundleOf import androidx.fragment.app.DialogFragment +import org.openedx.core.R +import org.openedx.core.domain.model.DownloadDialogResource import org.openedx.core.presentation.dialog.DefaultDialogBox +import org.openedx.core.presentation.dialog.downloaddialog.DownloadDialogManager.Companion.DOWNLOAD_SIZE_FACTOR +import org.openedx.core.presentation.dialog.downloaddialog.DownloadStorageErrorDialogFragment.Companion.STORAGE_BAR_MIN_SIZE import org.openedx.core.system.StorageManager import org.openedx.core.ui.AutoSizeText import org.openedx.core.ui.OpenEdXOutlinedButton import org.openedx.core.ui.theme.OpenEdXTheme import org.openedx.core.ui.theme.appColors import org.openedx.core.ui.theme.appTypography -import org.openedx.course.R -import org.openedx.course.domain.model.DownloadDialogResource -import org.openedx.course.presentation.download.DownloadDialogManager.Companion.DOWNLOAD_SIZE_FACTOR -import org.openedx.course.presentation.download.DownloadStorageErrorDialogFragment.Companion.STORAGE_BAR_MIN_SIZE import org.openedx.foundation.extension.parcelable import org.openedx.foundation.extension.toFileSize import org.openedx.foundation.system.PreviewFragmentManager -import org.openedx.core.R as coreR -class DownloadStorageErrorDialogFragment : DialogFragment() { +class DownloadStorageErrorDialogFragment : DialogFragment(), DownloadDialog { + + override var listener: DownloadDialogListener? = null override fun onCreateView( inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?, ) = ComposeView(requireContext()).apply { - dialog?.window?.setBackgroundDrawable(ColorDrawable(Color.TRANSPARENT)) + dialog?.window?.setBackgroundDrawable(Color.TRANSPARENT.toDrawable()) setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed) setContent { OpenEdXTheme { - val uiState = requireArguments().parcelable(ARG_UI_STATE) ?: return@OpenEdXTheme + val uiState = requireArguments().parcelable(ARG_UI_STATE) + ?: return@OpenEdXTheme val downloadDialogResource = DownloadDialogResource( - title = stringResource(id = R.string.course_device_storage_full), - description = stringResource(id = R.string.course_download_device_storage_full_dialog_description), - icon = painterResource(id = R.drawable.course_ic_error), + title = stringResource(id = R.string.core_device_storage_full), + description = stringResource(id = R.string.core_download_device_storage_full_dialog_description), + icon = painterResource(id = R.drawable.core_ic_error), ) DownloadStorageErrorDialogView( @@ -80,6 +83,7 @@ class DownloadStorageErrorDialogFragment : DialogFragment() { downloadDialogResource = downloadDialogResource, onCancelClick = { dismiss() + listener?.onCancelClick() } ) } @@ -87,7 +91,6 @@ class DownloadStorageErrorDialogFragment : DialogFragment() { } companion object { - const val DIALOG_TAG = "DownloadStorageErrorDialogFragment" const val ARG_UI_STATE = "uiState" const val STORAGE_BAR_MIN_SIZE = 0.1f @@ -118,7 +121,6 @@ private fun DownloadStorageErrorDialogView( Column( modifier = Modifier .fillMaxWidth() - .verticalScroll(scrollState) .padding(20.dp), verticalArrangement = Arrangement.spacedBy(12.dp) ) { @@ -141,7 +143,11 @@ private fun DownloadStorageErrorDialogView( minSize = MaterialTheme.appTypography.titleLarge.fontSize.value - 1 ) } - Column { + Column( + modifier = Modifier + .heightIn(max = DownloadDialogManager.listMaxSize) + .verticalScroll(scrollState) + ) { uiState.downloadDialogItems.forEach { DownloadDialogItem(downloadDialogItem = it.copy(size = it.size * DOWNLOAD_SIZE_FACTOR)) } @@ -158,7 +164,7 @@ private fun DownloadStorageErrorDialogView( ) OpenEdXOutlinedButton( modifier = Modifier.fillMaxWidth(), - text = stringResource(id = coreR.string.core_cancel), + text = stringResource(id = R.string.core_cancel), backgroundColor = MaterialTheme.appColors.background, borderColor = MaterialTheme.appColors.primaryButtonBackground, textColor = MaterialTheme.appColors.primaryButtonBackground, @@ -214,7 +220,12 @@ private fun StorageBar( modifier = Modifier .weight(freePercentage) .fillMaxHeight() - .padding(top = boxPadding, bottom = boxPadding, start = boxPadding, end = boxPadding / 2) + .padding( + top = boxPadding, + bottom = boxPadding, + start = boxPadding, + end = boxPadding / 2 + ) .clip(RoundedCornerShape(topStart = cornerRadius, bottomStart = cornerRadius)) .background(MaterialTheme.appColors.cardViewBorder) ) @@ -222,7 +233,12 @@ private fun StorageBar( modifier = Modifier .weight(animReqPercentage.value) .fillMaxHeight() - .padding(top = boxPadding, bottom = boxPadding, end = boxPadding, start = boxPadding / 2) + .padding( + top = boxPadding, + bottom = boxPadding, + end = boxPadding, + start = boxPadding / 2 + ) .clip(RoundedCornerShape(topEnd = cornerRadius, bottomEnd = cornerRadius)) .background(MaterialTheme.appColors.error) ) @@ -233,7 +249,7 @@ private fun StorageBar( ) { Text( text = stringResource( - R.string.course_used_free_storage, + R.string.core_used_free_storage, usedSpace.toFileSize(1, false), freeSpace.toFileSize(1, false) ), @@ -258,7 +274,7 @@ private fun DownloadStorageErrorDialogViewPreview() { downloadDialogResource = DownloadDialogResource( title = "Title", description = "Description Description Description Description Description Description Description ", - icon = painterResource(id = R.drawable.course_ic_error) + icon = painterResource(id = R.drawable.core_ic_error) ), uiState = DownloadDialogUIState( downloadDialogItems = listOf( diff --git a/course/src/main/java/org/openedx/course/presentation/download/DownloadView.kt b/core/src/main/java/org/openedx/core/presentation/dialog/downloaddialog/DownloadView.kt similarity index 97% rename from course/src/main/java/org/openedx/course/presentation/download/DownloadView.kt rename to core/src/main/java/org/openedx/core/presentation/dialog/downloaddialog/DownloadView.kt index fd70dd723..4469f0b8e 100644 --- a/course/src/main/java/org/openedx/course/presentation/download/DownloadView.kt +++ b/core/src/main/java/org/openedx/core/presentation/dialog/downloaddialog/DownloadView.kt @@ -1,4 +1,4 @@ -package org.openedx.course.presentation.download +package org.openedx.core.presentation.dialog.downloaddialog import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Row 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 527a7ce51..be653a3ed 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 @@ -12,6 +12,7 @@ class CourseNotifier { suspend fun send(event: CourseVideoPositionChanged) = channel.emit(event) suspend fun send(event: CourseStructureUpdated) = channel.emit(event) + suspend fun send(event: CourseStructureGot) = channel.emit(event) suspend fun send(event: CourseSubtitleLanguageChanged) = channel.emit(event) suspend fun send(event: CourseSectionChanged) = channel.emit(event) suspend fun send(event: CourseCompletionSet) = channel.emit(event) diff --git a/core/src/main/java/org/openedx/core/system/notifier/CourseStructureGot.kt b/core/src/main/java/org/openedx/core/system/notifier/CourseStructureGot.kt new file mode 100644 index 000000000..d685519e3 --- /dev/null +++ b/core/src/main/java/org/openedx/core/system/notifier/CourseStructureGot.kt @@ -0,0 +1,5 @@ +package org.openedx.core.system.notifier + +class CourseStructureGot( + val courseId: String +) : 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 aaaa0711d..9961c2887 100644 --- a/core/src/main/java/org/openedx/core/ui/ComposeCommon.kt +++ b/core/src/main/java/org/openedx/core/ui/ComposeCommon.kt @@ -222,6 +222,40 @@ fun Toolbar( } } +@Composable +fun MainToolbar( + 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 = R.string.core_accessibility_settings) + ) + } + } +} + @Composable fun SearchBar( modifier: Modifier, @@ -1404,6 +1438,23 @@ private fun RoundTab( } } +@Composable +fun OpenEdXDropdownMenuItem( + modifier: Modifier = Modifier, + text: String, + onClick: () -> Unit +) { + Text( + modifier = modifier + .fillMaxWidth() + .clickable { onClick() } + .padding(16.dp), + text = text, + style = MaterialTheme.appTypography.labelLarge, + color = MaterialTheme.appColors.textDark, + ) +} + @Preview @Composable private fun StaticSearchBarPreview() { diff --git a/course/src/main/res/drawable/course_download_waiting.png b/core/src/main/res/drawable/core_download_waiting.png similarity index 100% rename from course/src/main/res/drawable/course_download_waiting.png rename to core/src/main/res/drawable/core_download_waiting.png diff --git a/course/src/main/res/drawable/course_ic_error.xml b/core/src/main/res/drawable/core_ic_error.xml similarity index 100% rename from course/src/main/res/drawable/course_ic_error.xml rename to core/src/main/res/drawable/core_ic_error.xml diff --git a/core/src/main/res/values/strings.xml b/core/src/main/res/values/strings.xml index f15a693bb..99df5b3d4 100644 --- a/core/src/main/res/values/strings.xml +++ b/core/src/main/res/values/strings.xml @@ -187,6 +187,46 @@ Not Synced Syncing to calendar… Next + + Downloads + (Untitled) + Download + The videos you\'ve selected are larger than 1 GB. Do you want to download these videos? + 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 - %2$s - %3$d / %4$d + Downloading this content requires an active internet connection. Please connect to the internet and try again. + Wi-Fi Required + Downloading this content requires an active WiFi connection. Please connect to a WiFi network and try again. + Download Failed + Unfortunately, this content failed to download. Please try again later or report this issue. + Downloading this %1$s of content will save available blocks offline. + Download on Cellular? + Downloading this content will use %1$s of cellular data. + Remove Offline Content? + Removing this content will free up %1$s. + Download + Remove + Device Storage Full + Your device does not have enough free space to download this content. Please free up some space and try again. + %1$s used, %2$s free + 0MB + Available to download + None of this course’s content is currently available to download offline. + Download all + Downloaded + Ready to Download + You can download course content offline to learn on the go, without requiring an active internet connection or using mobile data. + Downloading + Largest Downloads + Remove all downloads + Cancel Course Download + This component is not yet available offline + Explore other parts of this course or view this when you reconnect. + This component is not downloaded + Explore other parts of this course or download this when you reconnect. + Authorization Please enter the system to continue with course enrollment. diff --git a/course/src/main/java/org/openedx/course/data/repository/CourseRepository.kt b/course/src/main/java/org/openedx/course/data/repository/CourseRepository.kt index bc508821d..bf39cc80c 100644 --- a/course/src/main/java/org/openedx/course/data/repository/CourseRepository.kt +++ b/course/src/main/java/org/openedx/course/data/repository/CourseRepository.kt @@ -9,6 +9,7 @@ import org.openedx.core.data.model.BlocksCompletionBody import org.openedx.core.data.model.room.OfflineXBlockProgress import org.openedx.core.data.model.room.XBlockProgressData import org.openedx.core.data.storage.CorePreferences +import org.openedx.core.data.storage.CourseDao import org.openedx.core.domain.model.CourseComponentStatus import org.openedx.core.domain.model.CourseDatesBannerInfo import org.openedx.core.domain.model.CourseDatesResult @@ -18,7 +19,6 @@ import org.openedx.core.exception.NoCachedDataException import org.openedx.core.extension.channelFlowWithAwait import org.openedx.core.module.db.DownloadDao import org.openedx.core.system.connection.NetworkConnection -import org.openedx.course.data.storage.CourseDao import java.net.URLDecoder import java.nio.charset.StandardCharsets @@ -218,7 +218,11 @@ class CourseRepository( submitOfflineXBlockProgress(blockId, courseId, jsonProgressData) } - private suspend fun submitOfflineXBlockProgress(blockId: String, courseId: String, jsonProgressData: String?) { + private suspend fun submitOfflineXBlockProgress( + blockId: String, + courseId: String, + jsonProgressData: String? + ) { if (!jsonProgressData.isNullOrEmpty()) { val parts = mutableListOf() val decodedQuery = URLDecoder.decode(jsonProgressData, StandardCharsets.UTF_8.name()) diff --git a/course/src/main/java/org/openedx/course/domain/interactor/CourseInteractor.kt b/course/src/main/java/org/openedx/course/domain/interactor/CourseInteractor.kt index 4678c9115..8fab7bba7 100644 --- a/course/src/main/java/org/openedx/course/domain/interactor/CourseInteractor.kt +++ b/course/src/main/java/org/openedx/course/domain/interactor/CourseInteractor.kt @@ -2,6 +2,7 @@ package org.openedx.course.domain.interactor import kotlinx.coroutines.flow.Flow import org.openedx.core.BlockType +import org.openedx.core.domain.interactor.CourseInteractor import org.openedx.core.domain.model.Block import org.openedx.core.domain.model.CourseEnrollmentDetails import org.openedx.core.domain.model.CourseStructure @@ -10,7 +11,7 @@ import org.openedx.course.data.repository.CourseRepository @Suppress("TooManyFunctions") class CourseInteractor( private val repository: CourseRepository -) { +) : CourseInteractor { suspend fun getCourseStructureFlow( courseId: String, @@ -19,14 +20,14 @@ class CourseInteractor( return repository.getCourseStructureFlow(courseId, forceRefresh) } - suspend fun getCourseStructure( + override suspend fun getCourseStructure( courseId: String, - isNeedRefresh: Boolean = false + isNeedRefresh: Boolean ): CourseStructure { return repository.getCourseStructure(courseId, isNeedRefresh) } - suspend fun getCourseStructureFromCache(courseId: String): CourseStructure { + override suspend fun getCourseStructureFromCache(courseId: String): CourseStructure { return repository.getCourseStructureFromCache(courseId) } @@ -101,7 +102,7 @@ class CourseInteractor( fun getDownloadModels() = repository.getDownloadModels() - suspend fun getAllDownloadModels() = repository.getAllDownloadModels() + override suspend fun getAllDownloadModels() = repository.getAllDownloadModels() suspend fun saveXBlockProgress(blockId: String, courseId: String, jsonProgress: String) { repository.saveOfflineXBlockProgress(blockId, courseId, jsonProgress) diff --git a/course/src/main/java/org/openedx/course/presentation/container/CourseContainerTab.kt b/course/src/main/java/org/openedx/course/presentation/container/CourseContainerTab.kt index 255b7e88b..b591c7ecf 100644 --- a/course/src/main/java/org/openedx/course/presentation/container/CourseContainerTab.kt +++ b/course/src/main/java/org/openedx/course/presentation/container/CourseContainerTab.kt @@ -4,9 +4,9 @@ import androidx.annotation.StringRes import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.Chat import androidx.compose.material.icons.automirrored.filled.TextSnippet +import androidx.compose.material.icons.filled.CloudDownload import androidx.compose.material.icons.filled.Home import androidx.compose.material.icons.outlined.CalendarMonth -import androidx.compose.material.icons.outlined.CloudDownload import androidx.compose.material.icons.rounded.PlayCircleFilled import androidx.compose.ui.graphics.vector.ImageVector import org.openedx.core.ui.TabItem @@ -20,7 +20,7 @@ enum class CourseContainerTab( 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), - OFFLINE(R.string.course_container_nav_downloads, Icons.Outlined.CloudDownload), + OFFLINE(R.string.course_container_nav_downloads, Icons.Filled.CloudDownload), DISCUSSIONS(R.string.course_container_nav_discussions, Icons.AutoMirrored.Filled.Chat), MORE(R.string.course_container_nav_more, Icons.AutoMirrored.Filled.TextSnippet) } 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 0e7288423..f3d2bd2c7 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 @@ -3,6 +3,7 @@ package org.openedx.course.presentation.container import android.graphics.Bitmap import android.graphics.drawable.BitmapDrawable import android.os.Build +import androidx.core.graphics.createBitmap import androidx.lifecycle.LiveData import androidx.lifecycle.MutableLiveData import androidx.lifecycle.viewModelScope @@ -35,6 +36,7 @@ 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.CourseStructureGot import org.openedx.core.system.notifier.CourseStructureUpdated import org.openedx.core.system.notifier.RefreshDates import org.openedx.core.system.notifier.RefreshDiscussions @@ -116,7 +118,7 @@ class CourseContainerViewModel( val calendarSyncUIState: StateFlow = _calendarSyncUIState.asStateFlow() - private var _courseImage = MutableStateFlow(Bitmap.createBitmap(1, 1, Bitmap.Config.ARGB_8888)) + private var _courseImage = MutableStateFlow(createBitmap(1, 1)) val courseImage: StateFlow = _courseImage.asStateFlow() val hasInternetConnection: Boolean @@ -187,6 +189,7 @@ class CourseContainerViewModel( courseStructure != null -> handleCourseStructureOnly(courseStructure) else -> _courseAccessStatus.value = CourseAccessError.UNKNOWN } + courseNotifier.send(CourseStructureGot(courseId)) } } } diff --git a/course/src/main/java/org/openedx/course/presentation/offline/CourseOfflineScreen.kt b/course/src/main/java/org/openedx/course/presentation/offline/CourseOfflineScreen.kt index e7c69397a..0356b0164 100644 --- a/course/src/main/java/org/openedx/course/presentation/offline/CourseOfflineScreen.kt +++ b/course/src/main/java/org/openedx/course/presentation/offline/CourseOfflineScreen.kt @@ -49,6 +49,7 @@ import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import androidx.fragment.app.FragmentManager +import org.openedx.core.R import org.openedx.core.module.db.DownloadModel import org.openedx.core.module.db.DownloadedState import org.openedx.core.module.db.FileType @@ -59,12 +60,10 @@ import org.openedx.core.ui.displayCutoutForLandscape import org.openedx.core.ui.theme.OpenEdXTheme import org.openedx.core.ui.theme.appColors import org.openedx.core.ui.theme.appTypography -import org.openedx.course.R import org.openedx.foundation.extension.toFileSize import org.openedx.foundation.presentation.WindowSize import org.openedx.foundation.presentation.rememberWindowSize import org.openedx.foundation.presentation.windowSizeValue -import org.openedx.core.R as coreR @Composable fun CourseOfflineScreen( @@ -159,7 +158,7 @@ private fun CourseOfflineUI( if (uiState.progressBarValue != 1f && !uiState.isDownloading && hasInternetConnection) { Spacer(modifier = Modifier.height(20.dp)) OpenEdXButton( - text = stringResource(R.string.course_download_all), + text = stringResource(R.string.core_download_all), backgroundColor = MaterialTheme.appColors.secondaryButtonBackground, onClick = onDownloadAllClick, enabled = uiState.isHaveDownloadableBlocks, @@ -170,7 +169,7 @@ private fun CourseOfflineUI( MaterialTheme.appColors.textPrimaryVariant } IconText( - text = stringResource(R.string.course_download_all), + text = stringResource(R.string.core_download_all), icon = Icons.Outlined.CloudDownload, color = textColor, textStyle = MaterialTheme.appTypography.labelLarge @@ -181,14 +180,14 @@ private fun CourseOfflineUI( Spacer(modifier = Modifier.height(20.dp)) OpenEdXOutlinedButton( modifier = Modifier.fillMaxWidth(), - text = stringResource(R.string.course_cancel_course_download), + text = stringResource(R.string.core_cancel_course_download), backgroundColor = MaterialTheme.appColors.background, borderColor = MaterialTheme.appColors.error, textColor = MaterialTheme.appColors.error, onClick = onCancelDownloadClick, content = { IconText( - text = stringResource(R.string.course_cancel_course_download), + text = stringResource(R.string.core_cancel_course_download), icon = Icons.Rounded.Close, color = MaterialTheme.appColors.error, textStyle = MaterialTheme.appTypography.labelLarge @@ -223,9 +222,9 @@ private fun LargestDownloads( mutableStateOf(false) } val text = if (!isEditingEnabled) { - stringResource(coreR.string.core_edit) + stringResource(R.string.core_edit) } else { - stringResource(coreR.string.core_label_done) + stringResource(R.string.core_label_done) } LaunchedEffect(isDownloading) { @@ -238,7 +237,7 @@ private fun LargestDownloads( Row { Text( modifier = Modifier.weight(1f), - text = stringResource(R.string.course_largest_downloads), + text = stringResource(R.string.core_largest_downloads), style = MaterialTheme.appTypography.titleMedium, color = MaterialTheme.appColors.textDark ) @@ -264,14 +263,14 @@ private fun LargestDownloads( if (!isDownloading) { OpenEdXOutlinedButton( modifier = Modifier.fillMaxWidth(), - text = stringResource(R.string.course_remove_all_downloads), + text = stringResource(R.string.core_remove_all_downloads), backgroundColor = MaterialTheme.appColors.background, borderColor = MaterialTheme.appColors.error, textColor = MaterialTheme.appColors.error, onClick = onDeleteAllClick, content = { IconText( - text = stringResource(R.string.course_remove_all_downloads), + text = stringResource(R.string.core_remove_all_downloads), icon = Icons.Rounded.Delete, color = MaterialTheme.appColors.error, textStyle = MaterialTheme.appTypography.labelLarge @@ -384,21 +383,21 @@ private fun DownloadProgress( horizontalArrangement = Arrangement.SpaceBetween ) { IconText( - text = stringResource(R.string.course_downloaded), + text = stringResource(R.string.core_downloaded), icon = Icons.Default.CloudDone, color = MaterialTheme.appColors.successGreen, textStyle = MaterialTheme.appTypography.labelLarge ) if (!uiState.isDownloading) { IconText( - text = stringResource(R.string.course_ready_to_download), + text = stringResource(R.string.core_ready_to_download), icon = Icons.Outlined.CloudDownload, color = MaterialTheme.appColors.textDark, textStyle = MaterialTheme.appTypography.labelLarge ) } else { IconText( - text = stringResource(R.string.course_downloading), + text = stringResource(R.string.core_downloading), icon = Icons.Outlined.CloudDownload, color = MaterialTheme.appColors.textDark, textStyle = MaterialTheme.appTypography.labelLarge @@ -418,7 +417,7 @@ private fun DownloadProgress( ) } else { Text( - text = stringResource(R.string.course_you_can_download_course_content_offline), + text = stringResource(R.string.core_you_can_download_course_content_offline), style = MaterialTheme.appTypography.labelLarge, color = MaterialTheme.appColors.textDark ) @@ -434,20 +433,20 @@ private fun NoDownloadableBlocksProgress( modifier = modifier ) { Text( - text = stringResource(R.string.course_0mb), + text = stringResource(R.string.core_0mb), style = MaterialTheme.appTypography.titleLarge, color = MaterialTheme.appColors.textFieldHint ) Spacer(modifier = Modifier.height(4.dp)) IconText( - text = stringResource(R.string.course_available_to_download), + text = stringResource(R.string.core_available_to_download), icon = Icons.Outlined.CloudDownload, color = MaterialTheme.appColors.textFieldHint, textStyle = MaterialTheme.appTypography.labelLarge ) Spacer(modifier = Modifier.height(20.dp)) Text( - text = stringResource(R.string.course_no_available_to_download_offline), + text = stringResource(R.string.core_no_available_to_download_offline), style = MaterialTheme.appTypography.labelLarge, color = MaterialTheme.appColors.textDark ) diff --git a/course/src/main/java/org/openedx/course/presentation/offline/CourseOfflineViewModel.kt b/course/src/main/java/org/openedx/course/presentation/offline/CourseOfflineViewModel.kt index 19d67f79b..8f3637b24 100644 --- a/course/src/main/java/org/openedx/course/presentation/offline/CourseOfflineViewModel.kt +++ b/course/src/main/java/org/openedx/course/presentation/offline/CourseOfflineViewModel.kt @@ -21,10 +21,10 @@ import org.openedx.core.module.db.FileType import org.openedx.core.module.download.BaseDownloadViewModel import org.openedx.core.module.download.DownloadHelper import org.openedx.core.presentation.CoreAnalytics +import org.openedx.core.presentation.dialog.downloaddialog.DownloadDialogItem +import org.openedx.core.presentation.dialog.downloaddialog.DownloadDialogManager import org.openedx.core.system.connection.NetworkConnection import org.openedx.course.domain.interactor.CourseInteractor -import org.openedx.course.presentation.download.DownloadDialogItem -import org.openedx.course.presentation.download.DownloadDialogManager import org.openedx.foundation.extension.toFileSize import org.openedx.foundation.utils.FileUtil @@ -41,7 +41,6 @@ class CourseOfflineViewModel( workerController: DownloadWorkerController, downloadHelper: DownloadHelper, ) : BaseDownloadViewModel( - courseId, downloadDao, preferencesManager, workerController, @@ -100,7 +99,7 @@ class CourseOfflineViewModel( fragmentManager = fragmentManager, removeDownloadModels = ::removeDownloadModels, saveDownloadModels = { blockId -> - saveDownloadModels(fileUtil.getExternalAppDir().path, blockId) + saveDownloadModels(fileUtil.getExternalAppDir().path, courseId, blockId) } ) } @@ -127,12 +126,12 @@ class CourseOfflineViewModel( fun deleteAll(fragmentManager: FragmentManager) { viewModelScope.launch { - val downloadModels = courseInteractor.getAllDownloadModels().filter { it.courseId == courseId } + val downloadModels = + courseInteractor.getAllDownloadModels().filter { it.courseId == courseId } val totalSize = downloadModels.sumOf { it.size } val downloadDialogItem = DownloadDialogItem( title = courseTitle, size = totalSize, - icon = Icons.AutoMirrored.Outlined.InsertDriveFile ) downloadDialogManager.showRemoveDownloadModelPopup( downloadDialogItem = downloadDialogItem, @@ -171,7 +170,8 @@ class CourseOfflineViewModel( val completedDownloads = downloadModels.filter { it.downloadedState.isDownloaded && it.courseId == courseId } val completedDownloadIds = completedDownloads.map { it.id } - val downloadedBlocks = courseStructure.blockData.filter { it.id in completedDownloadIds } + val downloadedBlocks = + courseStructure.blockData.filter { it.id in completedDownloadIds } updateUIState( totalDownloadableSize, @@ -192,14 +192,19 @@ class CourseOfflineViewModel( val largestDownloads = completedDownloads .sortedByDescending { it.size } .take(n = 5) - + val progressBarValue = downloadedSize.toFloat() / totalDownloadableSize.toFloat() + val readyToDownloadSize = if (progressBarValue >= 1) { + 0 + } else { + totalDownloadableSize - realDownloadedSize + } _uiState.update { it.copy( isHaveDownloadableBlocks = true, largestDownloads = largestDownloads, - readyToDownloadSize = (totalDownloadableSize - downloadedSize).toFileSize(1, false), + readyToDownloadSize = readyToDownloadSize.toFileSize(1, false), downloadedSize = realDownloadedSize.toFileSize(1, false), - progressBarValue = downloadedSize.toFloat() / totalDownloadableSize.toFloat() + progressBarValue = progressBarValue ) } } 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 916213026..50fedd2dc 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 @@ -28,6 +28,7 @@ import org.openedx.core.module.download.BaseDownloadViewModel import org.openedx.core.module.download.DownloadHelper import org.openedx.core.presentation.CoreAnalytics import org.openedx.core.presentation.course.CourseViewMode +import org.openedx.core.presentation.dialog.downloaddialog.DownloadDialogManager import org.openedx.core.presentation.settings.calendarsync.CalendarSyncDialogType import org.openedx.core.system.connection.NetworkConnection import org.openedx.core.system.notifier.CalendarSyncEvent.CreateCalendarSyncEvent @@ -40,7 +41,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.download.DownloadDialogManager import org.openedx.foundation.extension.isInternetError import org.openedx.foundation.presentation.UIMessage import org.openedx.foundation.system.ResourceManager @@ -65,7 +65,6 @@ class CourseOutlineViewModel( workerController: DownloadWorkerController, downloadHelper: DownloadHelper, ) : BaseDownloadViewModel( - courseId, downloadDao, preferencesManager, workerController, @@ -136,10 +135,10 @@ class CourseOutlineViewModel( getCourseData() } - override fun saveDownloadModels(folder: String, id: String) { + override fun saveDownloadModels(folder: String, courseId: String, id: String) { if (preferencesManager.videoSettings.wifiDownloadOnly) { if (networkConnection.isWifiConnected()) { - super.saveDownloadModels(folder, id) + super.saveDownloadModels(folder, courseId, id) } else { viewModelScope.launch { _uiMessage.emit( @@ -150,7 +149,7 @@ class CourseOutlineViewModel( } } } else { - super.saveDownloadModels(folder, id) + super.saveDownloadModels(folder, courseId, id) } } @@ -472,7 +471,7 @@ class CourseOutlineViewModel( fragmentManager = fragmentManager, removeDownloadModels = ::removeDownloadModels, saveDownloadModels = { blockId -> - saveDownloadModels(fileUtil.getExternalAppDir().path, blockId) + saveDownloadModels(fileUtil.getExternalAppDir().path, courseId, blockId) } ) } @@ -499,7 +498,11 @@ class CourseOutlineViewModel( outdatedBlockIds.forEach { blockId -> interactor.removeDownloadModel(blockId) } - saveDownloadModels(fileUtil.getExternalAppDir().path, outdatedBlockIds) + saveDownloadModels( + fileUtil.getExternalAppDir().path, + courseId, + outdatedBlockIds + ) } isOfflineBlocksUpToDate = true } diff --git a/course/src/main/java/org/openedx/course/presentation/ui/CourseUI.kt b/course/src/main/java/org/openedx/course/presentation/ui/CourseUI.kt index 1a6cd60a7..695049c75 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 @@ -246,7 +246,7 @@ fun OfflineQueueCard( .weight(1f) ) { Text( - text = downloadModel.title.ifEmpty { stringResource(id = R.string.course_download_untitled) }, + text = downloadModel.title.ifEmpty { stringResource(id = coreR.string.core_download_untitled) }, style = MaterialTheme.appTypography.titleSmall, color = MaterialTheme.appColors.textPrimary, overflow = TextOverflow.Ellipsis, @@ -748,7 +748,7 @@ fun CourseExpandableChapterCard( ) } else if (downloadedState == DownloadedState.WAITING) { Icon( - painter = painterResource(id = R.drawable.course_download_waiting), + painter = painterResource(id = coreR.drawable.core_download_waiting), contentDescription = stringResource( id = R.string.course_accessibility_stop_downloading_course_section ), @@ -832,7 +832,7 @@ fun CourseSubSectionItem( if (isAssignmentEnable) { val assignmentString = stringResource( - R.string.course_subsection_assignment_info, + coreR.string.core_subsection_assignment_info, block.assignmentProgress?.assignmentType ?: "", stringResource(id = coreR.string.core_date_format_assignment_due, due), block.assignmentProgress?.numPointsEarned?.toInt() ?: 0, 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 5e2c0b8fa..5f498c162 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 @@ -71,7 +71,6 @@ import org.openedx.core.ui.displayCutoutForLandscape import org.openedx.core.ui.theme.OpenEdXTheme import org.openedx.core.ui.theme.appColors import org.openedx.core.ui.theme.appTypography -import org.openedx.course.R import org.openedx.course.presentation.videos.CourseVideoViewModel import org.openedx.course.presentation.videos.CourseVideosUIState import org.openedx.foundation.extension.toFileSize @@ -81,6 +80,7 @@ import org.openedx.foundation.presentation.WindowType import org.openedx.foundation.presentation.windowSizeValue import org.openedx.foundation.utils.FileUtil import java.util.Date +import org.openedx.core.R as coreR @Composable fun CourseVideosScreen( @@ -132,12 +132,16 @@ fun CourseVideosScreen( ) }, onDownloadAllClick = { isAllBlocksDownloadedOrDownloading -> - viewModel.logBulkDownloadToggleEvent(!isAllBlocksDownloadedOrDownloading) + viewModel.logBulkDownloadToggleEvent( + !isAllBlocksDownloadedOrDownloading, + viewModel.courseId + ) if (isAllBlocksDownloadedOrDownloading) { viewModel.removeAllDownloadModels() } else { viewModel.saveAllDownloadModels( - fileUtil.getExternalAppDir().path + fileUtil.getExternalAppDir().path, + viewModel.courseId ) } }, @@ -308,12 +312,12 @@ private fun CourseVideosUI( AlertDialog( title = { Text( - text = stringResource(id = R.string.course_download_big_files_confirmation_title) + text = stringResource(id = coreR.string.core_download_big_files_confirmation_title) ) }, text = { Text( - text = stringResource(id = R.string.course_download_big_files_confirmation_text) + text = stringResource(id = coreR.string.core_download_big_files_confirmation_text) ) }, onDismissRequest = { @@ -344,14 +348,15 @@ private fun CourseVideosUI( } if (isDeleteDownloadsConfirmationShowed) { - val downloadModelsSize = (uiState as? CourseVideosUIState.CourseData)?.downloadModelsSize + val downloadModelsSize = + (uiState as? CourseVideosUIState.CourseData)?.downloadModelsSize val isDownloadedAllVideos = downloadModelsSize?.isAllBlocksDownloadedOrDownloading == true && downloadModelsSize.remainingCount == 0 val dialogTextId = if (isDownloadedAllVideos) { - R.string.course_delete_confirmation + coreR.string.core_delete_confirmation } else { - R.string.course_delete_in_process_confirmation + coreR.string.core_delete_in_process_confirmation } AlertDialog( @@ -402,7 +407,7 @@ private fun CourseVideosUI( text = { Text( text = stringResource( - id = R.string.course_delete_download_confirmation_text, + id = coreR.string.core_delete_download_confirmation_text, deleteDownloadBlock?.displayName ?: "" ) ) diff --git a/course/src/main/java/org/openedx/course/presentation/unit/NotAvailableUnitFragment.kt b/course/src/main/java/org/openedx/course/presentation/unit/NotAvailableUnitFragment.kt index 5fe50a0e6..9e4dbde3e 100644 --- a/course/src/main/java/org/openedx/course/presentation/unit/NotAvailableUnitFragment.kt +++ b/course/src/main/java/org/openedx/course/presentation/unit/NotAvailableUnitFragment.kt @@ -45,6 +45,7 @@ import org.openedx.foundation.extension.parcelable import org.openedx.foundation.presentation.WindowSize import org.openedx.foundation.presentation.rememberWindowSize import org.openedx.foundation.presentation.windowSizeValue +import org.openedx.core.R as coreR import org.openedx.course.R as courseR class NotAvailableUnitFragment : Fragment() { @@ -80,14 +81,15 @@ class NotAvailableUnitFragment : Fragment() { } NotAvailableUnitType.OFFLINE_UNSUPPORTED -> { - title = stringResource(id = courseR.string.course_not_available_offline) - description = stringResource(id = courseR.string.course_explore_other_parts_when_reconnect) + title = stringResource(id = coreR.string.core_not_available_offline) + description = + stringResource(id = coreR.string.core_explore_other_parts_when_reconnect) } NotAvailableUnitType.NOT_DOWNLOADED -> { - title = stringResource(id = courseR.string.course_not_downloaded) + title = stringResource(id = coreR.string.core_not_downloaded) description = - stringResource(id = courseR.string.course_explore_other_parts_when_reconnect_or_download) + stringResource(id = coreR.string.core_explore_other_parts_when_reconnect_or_download) } else -> { 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 809a399eb..242b667b7 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 @@ -19,6 +19,7 @@ import org.openedx.core.module.db.DownloadDao import org.openedx.core.module.download.BaseDownloadViewModel import org.openedx.core.module.download.DownloadHelper import org.openedx.core.presentation.CoreAnalytics +import org.openedx.core.presentation.dialog.downloaddialog.DownloadDialogManager import org.openedx.core.system.connection.NetworkConnection import org.openedx.core.system.notifier.CourseLoading import org.openedx.core.system.notifier.CourseNotifier @@ -29,7 +30,6 @@ 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 org.openedx.course.presentation.download.DownloadDialogManager import org.openedx.foundation.presentation.UIMessage import org.openedx.foundation.system.ResourceManager import org.openedx.foundation.utils.FileUtil @@ -53,7 +53,6 @@ class CourseVideoViewModel( workerController: DownloadWorkerController, downloadHelper: DownloadHelper, ) : BaseDownloadViewModel( - courseId, downloadDao, preferencesManager, workerController, @@ -123,10 +122,10 @@ class CourseVideoViewModel( getVideos() } - override fun saveDownloadModels(folder: String, id: String) { + override fun saveDownloadModels(folder: String, courseId: String, id: String) { if (preferencesManager.videoSettings.wifiDownloadOnly) { if (networkConnection.isWifiConnected()) { - super.saveDownloadModels(folder, id) + super.saveDownloadModels(folder, courseId, id) } else { viewModelScope.launch { _uiMessage.emit( @@ -137,11 +136,11 @@ class CourseVideoViewModel( } } } else { - super.saveDownloadModels(folder, id) + super.saveDownloadModels(folder, courseId, id) } } - override fun saveAllDownloadModels(folder: String) { + override fun saveAllDownloadModels(folder: String, courseId: String) { if (preferencesManager.videoSettings.wifiDownloadOnly && !networkConnection.isWifiConnected()) { viewModelScope.launch { _uiMessage.emit( @@ -151,7 +150,7 @@ class CourseVideoViewModel( return } - super.saveAllDownloadModels(folder) + super.saveAllDownloadModels(folder, courseId) } fun getVideos() { @@ -261,10 +260,12 @@ class CourseVideoViewModel( viewModelScope.launch { val courseData = _uiState.value as? CourseVideosUIState.CourseData ?: return@launch - val subSectionsBlocks = courseData.courseSubSections.values.flatten().filter { it.id in blocksIds } + val subSectionsBlocks = + courseData.courseSubSections.values.flatten().filter { it.id in blocksIds } val blocks = subSectionsBlocks.flatMap { subSectionsBlock -> - val verticalBlocks = allBlocks.values.filter { it.id in subSectionsBlock.descendants } + val verticalBlocks = + allBlocks.values.filter { it.id in subSectionsBlock.descendants } allBlocks.values.filter { it.id in verticalBlocks.flatMap { it.descendants } } } @@ -273,9 +274,12 @@ class CourseVideoViewModel( val isAllBlocksDownloaded = downloadableBlocks.all { isBlockDownloaded(it.id) } val notDownloadedSubSectionBlocks = subSectionsBlocks.mapNotNull { subSectionsBlock -> - val verticalBlocks = allBlocks.values.filter { it.id in subSectionsBlock.descendants } + val verticalBlocks = + allBlocks.values.filter { it.id in subSectionsBlock.descendants } val notDownloadedBlocks = allBlocks.values.filter { - it.id in verticalBlocks.flatMap { it.descendants } && it.isDownloadable && !isBlockDownloaded(it.id) + it.id in verticalBlocks.flatMap { it.descendants } && it.isDownloadable && !isBlockDownloaded( + it.id + ) } if (notDownloadedBlocks.isNotEmpty()) subSectionsBlock else null } @@ -285,7 +289,8 @@ class CourseVideoViewModel( } if (downloadingBlocks.isNotEmpty()) { - val downloadableChildren = downloadingBlocks.flatMap { getDownloadableChildren(it).orEmpty() } + val downloadableChildren = + downloadingBlocks.flatMap { getDownloadableChildren(it).orEmpty() } if (config.getCourseUIConfig().isCourseDownloadQueueEnabled) { courseRouter.navigateToDownloadQueue(fragmentManager, downloadableChildren) } else { @@ -304,7 +309,7 @@ class CourseVideoViewModel( fragmentManager = fragmentManager, removeDownloadModels = ::removeDownloadModels, saveDownloadModels = { blockId -> - saveDownloadModels(fileUtil.getExternalAppDir().path, blockId) + saveDownloadModels(fileUtil.getExternalAppDir().path, courseId, blockId) } ) } diff --git a/course/src/main/java/org/openedx/course/settings/download/DownloadQueueFragment.kt b/course/src/main/java/org/openedx/course/settings/download/DownloadQueueFragment.kt index 4f63f6883..612056392 100644 --- a/course/src/main/java/org/openedx/course/settings/download/DownloadQueueFragment.kt +++ b/course/src/main/java/org/openedx/course/settings/download/DownloadQueueFragment.kt @@ -33,7 +33,6 @@ import androidx.compose.ui.platform.ViewCompositionStrategy 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.compose.ui.unit.dp @@ -53,12 +52,12 @@ import org.openedx.core.ui.theme.OpenEdXTheme import org.openedx.core.ui.theme.appColors import org.openedx.core.ui.theme.appShapes import org.openedx.core.ui.theme.appTypography -import org.openedx.course.R import org.openedx.course.presentation.ui.OfflineQueueCard import org.openedx.foundation.presentation.WindowSize import org.openedx.foundation.presentation.WindowType import org.openedx.foundation.presentation.rememberWindowSize import org.openedx.foundation.presentation.windowSizeValue +import org.openedx.core.R as coreR class DownloadQueueFragment : Fragment() { @@ -89,7 +88,7 @@ class DownloadQueueFragment : Fragment() { requireActivity().supportFragmentManager.popBackStack() }, onDownloadClick = { - viewModel.removeDownloadModels(it.id) + viewModel.removeDownloadModels(it.id, "") } ) } @@ -156,7 +155,7 @@ private fun DownloadQueueScreen( modifier = Modifier .fillMaxWidth() .padding(horizontal = 56.dp), - text = stringResource(id = R.string.course_download_queue_title), + text = stringResource(id = coreR.string.core_download_queue_title), color = MaterialTheme.appColors.textPrimary, style = MaterialTheme.appTypography.titleMedium, maxLines = 1, @@ -218,7 +217,7 @@ private fun DownloadQueueScreen( } } -@Preview(uiMode = Configuration.UI_MODE_NIGHT_NO, device = Devices.TABLET) +@Preview(uiMode = Configuration.UI_MODE_NIGHT_NO) @Preview(uiMode = Configuration.UI_MODE_NIGHT_YES) @Composable private fun DownloadQueueScreenPreview() { diff --git a/course/src/main/java/org/openedx/course/settings/download/DownloadQueueViewModel.kt b/course/src/main/java/org/openedx/course/settings/download/DownloadQueueViewModel.kt index 03c3c01c2..67e161378 100644 --- a/course/src/main/java/org/openedx/course/settings/download/DownloadQueueViewModel.kt +++ b/course/src/main/java/org/openedx/course/settings/download/DownloadQueueViewModel.kt @@ -22,7 +22,6 @@ class DownloadQueueViewModel( coreAnalytics: CoreAnalytics, downloadHelper: DownloadHelper, ) : BaseDownloadViewModel( - "", downloadDao, preferencesManager, workerController, @@ -74,7 +73,7 @@ class DownloadQueueViewModel( } } - override fun removeDownloadModels(blockId: String) { + override fun removeDownloadModels(blockId: String, courseId: String) { viewModelScope.launch { workerController.removeModel(blockId) } diff --git a/course/src/main/res/drawable/core_ic_error.xml b/course/src/main/res/drawable/core_ic_error.xml deleted file mode 100644 index 391e8b4c5..000000000 --- a/course/src/main/res/drawable/core_ic_error.xml +++ /dev/null @@ -1,5 +0,0 @@ - - - diff --git a/course/src/main/res/values/strings.xml b/course/src/main/res/values/strings.xml index 59c536295..e4ae9e39d 100644 --- a/course/src/main/res/values/strings.xml +++ b/course/src/main/res/values/strings.xml @@ -55,44 +55,6 @@ Section completed Section uncompleted - Downloads - (Untitled) - Download - The videos you\'ve selected are larger than 1 GB. Do you want to download these videos? - 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 - %2$s - %3$d / %4$d - Downloading this content requires an active internet connection. Please connect to the internet and try again. - Wi-Fi Required - Downloading this content requires an active WiFi connection. Please connect to a WiFi network and try again. - Download Failed - Unfortunately, this content failed to download. Please try again later or report this issue. - Downloading this %1$s of content will save available blocks offline. - Download on Cellular? - Downloading this content will use %1$s of cellular data. - Remove Offline Content? - Removing this content will free up %1$s. - Download - Remove - Device Storage Full - Your device does not have enough free space to download this content. Please free up some space and try again. - %1$s used, %2$s free - 0MB - Available to download - None of this course’s content is currently available to download offline. - Download all - Downloaded - Ready to Download - You can download course content offline to learn on the go, without requiring an active internet connection or using mobile data. - Downloading - Largest Downloads - Remove all downloads - Cancel Course Download - This component is not yet available offline - Explore other parts of this course or view this when you reconnect. - This component is not downloaded - Explore other parts of this course or download this when you reconnect. %1$s of %2$s assignment complete 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 c95916668..f4e21f843 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 @@ -52,13 +52,13 @@ import org.openedx.core.module.db.FileType import org.openedx.core.module.download.DownloadHelper import org.openedx.core.presentation.CoreAnalytics import org.openedx.core.presentation.CoreAnalyticsEvent +import org.openedx.core.presentation.dialog.downloaddialog.DownloadDialogManager import org.openedx.core.system.connection.NetworkConnection 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 org.openedx.course.presentation.download.DownloadDialogManager import org.openedx.foundation.presentation.UIMessage import org.openedx.foundation.system.ResourceManager import org.openedx.foundation.utils.FileUtil @@ -264,7 +264,9 @@ class CourseOutlineViewModelTest { any(), any(), any(), - any() + any(), + any(), + any(), ) } returns Unit coEvery { interactor.getCourseStatusFlow(any()) } returns flow { throw UnknownHostException() } @@ -581,7 +583,7 @@ class CourseOutlineViewModelTest { viewModel.uiMessage.first() as? UIMessage.SnackBarMessage } } - viewModel.saveDownloadModels("", "") + viewModel.saveDownloadModels("", "", "") advanceUntilIdle() verify(exactly = 1) { coreAnalytics.logEvent( @@ -633,7 +635,7 @@ class CourseOutlineViewModelTest { viewModel.uiMessage.first() as? UIMessage.SnackBarMessage } } - viewModel.saveDownloadModels("", "") + viewModel.saveDownloadModels("", "", "") advanceUntilIdle() assert(message.await()?.message.isNullOrEmpty()) 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 b84bb61eb..ae34756a5 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 @@ -45,6 +45,7 @@ import org.openedx.core.module.db.DownloadedState import org.openedx.core.module.db.FileType import org.openedx.core.module.download.DownloadHelper import org.openedx.core.presentation.CoreAnalytics +import org.openedx.core.presentation.dialog.downloaddialog.DownloadDialogManager import org.openedx.core.system.connection.NetworkConnection import org.openedx.core.system.notifier.CourseLoading import org.openedx.core.system.notifier.CourseNotifier @@ -54,7 +55,6 @@ 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 org.openedx.course.presentation.download.DownloadDialogManager import org.openedx.foundation.presentation.UIMessage import org.openedx.foundation.system.ResourceManager import org.openedx.foundation.utils.FileUtil @@ -198,7 +198,19 @@ class CourseVideoViewModelTest { every { config.getApiHostURL() } returns "http://localhost:8000" every { courseNotifier.notifier } returns flowOf(CourseLoading(false)) every { preferencesManager.isRelativeDatesEnabled } returns true - every { downloadDialogManager.showPopup(any(), any(), any(), any(), any(), any(), any()) } returns Unit + every { + downloadDialogManager.showPopup( + any(), + any(), + any(), + any(), + any(), + any(), + any(), + any(), + any(), + ) + } returns Unit } @After @@ -366,95 +378,97 @@ class CourseVideoViewModelTest { viewModel.uiMessage.first() as? UIMessage.SnackBarMessage } } - viewModel.saveDownloadModels("", "") + viewModel.saveDownloadModels("", "", "") advanceUntilIdle() assert(message.await()?.message.isNullOrEmpty()) } @Test - fun `saveDownloadModels only wifi download, with connection`() = runTest(UnconfinedTestDispatcher()) { - every { config.getCourseUIConfig().isCourseDropdownNavigationEnabled } returns false - every { preferencesManager.videoSettings } returns VideoSettings.default - val viewModel = CourseVideoViewModel( - "", - "", - config, - interactor, - resourceManager, - networkConnection, - preferencesManager, - courseNotifier, - videoNotifier, - analytics, - downloadDialogManager, - fileUtil, - courseRouter, - coreAnalytics, - downloadDao, - workerController, - downloadHelper, - ) - coEvery { interactor.getCourseStructureForVideos(any()) } returns courseStructure - coEvery { downloadDao.getAllDataFlow() } returns flow { emit(listOf(downloadModelEntity)) } - every { preferencesManager.videoSettings.wifiDownloadOnly } returns true - every { networkConnection.isWifiConnected() } returns true - coEvery { workerController.saveModels(any()) } returns Unit - coEvery { downloadDao.getAllDataFlow() } returns flow { - emit(listOf(DownloadModelEntity.createFrom(downloadModel))) - } - every { coreAnalytics.logEvent(any(), any()) } returns Unit - val message = async { - withTimeoutOrNull(5000) { - viewModel.uiMessage.first() as? UIMessage.SnackBarMessage + fun `saveDownloadModels only wifi download, with connection`() = + runTest(UnconfinedTestDispatcher()) { + every { config.getCourseUIConfig().isCourseDropdownNavigationEnabled } returns false + every { preferencesManager.videoSettings } returns VideoSettings.default + val viewModel = CourseVideoViewModel( + "", + "", + config, + interactor, + resourceManager, + networkConnection, + preferencesManager, + courseNotifier, + videoNotifier, + analytics, + downloadDialogManager, + fileUtil, + courseRouter, + coreAnalytics, + downloadDao, + workerController, + downloadHelper, + ) + coEvery { interactor.getCourseStructureForVideos(any()) } returns courseStructure + coEvery { downloadDao.getAllDataFlow() } returns flow { emit(listOf(downloadModelEntity)) } + every { preferencesManager.videoSettings.wifiDownloadOnly } returns true + every { networkConnection.isWifiConnected() } returns true + coEvery { workerController.saveModels(any()) } returns Unit + coEvery { downloadDao.getAllDataFlow() } returns flow { + emit(listOf(DownloadModelEntity.createFrom(downloadModel))) + } + every { coreAnalytics.logEvent(any(), any()) } returns Unit + val message = async { + withTimeoutOrNull(5000) { + viewModel.uiMessage.first() as? UIMessage.SnackBarMessage + } } - } - viewModel.saveDownloadModels("", "") - advanceUntilIdle() + viewModel.saveDownloadModels("", "", "") + advanceUntilIdle() - assert(message.await()?.message.isNullOrEmpty()) - } + assert(message.await()?.message.isNullOrEmpty()) + } @Test - fun `saveDownloadModels only wifi download, without connection`() = runTest(UnconfinedTestDispatcher()) { - every { config.getCourseUIConfig().isCourseDropdownNavigationEnabled } returns false - every { preferencesManager.videoSettings } returns VideoSettings.default - val viewModel = CourseVideoViewModel( - "", - "", - config, - interactor, - resourceManager, - networkConnection, - preferencesManager, - courseNotifier, - videoNotifier, - analytics, - downloadDialogManager, - fileUtil, - courseRouter, - coreAnalytics, - downloadDao, - workerController, - downloadHelper, - ) - every { preferencesManager.videoSettings.wifiDownloadOnly } returns true - every { networkConnection.isWifiConnected() } returns false - every { networkConnection.isOnline() } returns false - coEvery { interactor.getCourseStructureForVideos(any()) } returns courseStructure - coEvery { downloadDao.getAllDataFlow() } returns flow { emit(listOf(downloadModelEntity)) } - coEvery { workerController.saveModels(any()) } returns Unit - val message = async { - withTimeoutOrNull(5000) { - viewModel.uiMessage.first() as? UIMessage.SnackBarMessage + fun `saveDownloadModels only wifi download, without connection`() = + runTest(UnconfinedTestDispatcher()) { + every { config.getCourseUIConfig().isCourseDropdownNavigationEnabled } returns false + every { preferencesManager.videoSettings } returns VideoSettings.default + val viewModel = CourseVideoViewModel( + "", + "", + config, + interactor, + resourceManager, + networkConnection, + preferencesManager, + courseNotifier, + videoNotifier, + analytics, + downloadDialogManager, + fileUtil, + courseRouter, + coreAnalytics, + downloadDao, + workerController, + downloadHelper, + ) + every { preferencesManager.videoSettings.wifiDownloadOnly } returns true + every { networkConnection.isWifiConnected() } returns false + every { networkConnection.isOnline() } returns false + coEvery { interactor.getCourseStructureForVideos(any()) } returns courseStructure + coEvery { downloadDao.getAllDataFlow() } returns flow { emit(listOf(downloadModelEntity)) } + coEvery { workerController.saveModels(any()) } returns Unit + val message = async { + withTimeoutOrNull(5000) { + viewModel.uiMessage.first() as? UIMessage.SnackBarMessage + } } - } - viewModel.saveDownloadModels("", "") + viewModel.saveDownloadModels("", "", "") - advanceUntilIdle() + advanceUntilIdle() - assert(message.await()?.message.isNullOrEmpty()) - } + assert(message.await()?.message.isNullOrEmpty()) + } } diff --git a/dashboard/src/main/java/org/openedx/learn/presentation/LearnFragment.kt b/dashboard/src/main/java/org/openedx/learn/presentation/LearnFragment.kt index c6843a5f8..dd5c0eb34 100644 --- a/dashboard/src/main/java/org/openedx/learn/presentation/LearnFragment.kt +++ b/dashboard/src/main/java/org/openedx/learn/presentation/LearnFragment.kt @@ -5,7 +5,6 @@ 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 @@ -15,12 +14,10 @@ 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.collectAsState import androidx.compose.runtime.getValue @@ -44,6 +41,7 @@ import org.koin.androidx.viewmodel.ext.android.viewModel import org.koin.core.parameter.parametersOf import org.openedx.core.adapter.NavigationFragmentAdapter import org.openedx.core.presentation.global.viewBinding +import org.openedx.core.ui.MainToolbar import org.openedx.core.ui.crop import org.openedx.core.ui.displayCutoutForLandscape import org.openedx.core.ui.statusBarsInset @@ -55,7 +53,6 @@ import org.openedx.dashboard.databinding.FragmentLearnBinding import org.openedx.foundation.presentation.rememberWindowSize import org.openedx.foundation.presentation.windowSizeValue import org.openedx.learn.LearnType -import org.openedx.core.R as CoreR class LearnFragment : Fragment(R.layout.fragment_learn) { @@ -140,7 +137,7 @@ private fun Header( .then(contentWidth), horizontalAlignment = Alignment.CenterHorizontally ) { - Title( + MainToolbar( label = stringResource(id = R.string.dashboard_learn), onSettingsClick = { viewModel.onSettingsClick(fragmentManager) @@ -158,40 +155,6 @@ private fun Header( } } -@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, @@ -277,7 +240,7 @@ private fun LearnDropdownMenu( @Composable private fun HeaderPreview() { OpenEdXTheme { - Title( + MainToolbar( label = stringResource(id = R.string.dashboard_learn), onSettingsClick = {} ) diff --git a/default_config/dev/config.yaml b/default_config/dev/config.yaml index 4d1d694ec..a7f265a45 100644 --- a/default_config/dev/config.yaml +++ b/default_config/dev/config.yaml @@ -64,6 +64,10 @@ BRANCH: HOST: '' ALTERNATE_HOST: '' +EXPERIMENTAL_FEATURES: + APP_LEVEL_DOWNLOADS: + ENABLED: false + #Platform names PLATFORM_NAME: "OpenEdX" PLATFORM_FULL_NAME: "OpenEdX" diff --git a/default_config/prod/config.yaml b/default_config/prod/config.yaml index 4d1d694ec..a7f265a45 100644 --- a/default_config/prod/config.yaml +++ b/default_config/prod/config.yaml @@ -64,6 +64,10 @@ BRANCH: HOST: '' ALTERNATE_HOST: '' +EXPERIMENTAL_FEATURES: + APP_LEVEL_DOWNLOADS: + ENABLED: false + #Platform names PLATFORM_NAME: "OpenEdX" PLATFORM_FULL_NAME: "OpenEdX" diff --git a/default_config/stage/config.yaml b/default_config/stage/config.yaml index 4d1d694ec..a7f265a45 100644 --- a/default_config/stage/config.yaml +++ b/default_config/stage/config.yaml @@ -64,6 +64,10 @@ BRANCH: HOST: '' ALTERNATE_HOST: '' +EXPERIMENTAL_FEATURES: + APP_LEVEL_DOWNLOADS: + ENABLED: false + #Platform names PLATFORM_NAME: "OpenEdX" PLATFORM_FULL_NAME: "OpenEdX" diff --git a/downloads/.gitignore b/downloads/.gitignore new file mode 100644 index 000000000..42afabfd2 --- /dev/null +++ b/downloads/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/downloads/build.gradle b/downloads/build.gradle new file mode 100644 index 000000000..df169ecd9 --- /dev/null +++ b/downloads/build.gradle @@ -0,0 +1,65 @@ +plugins { + id 'com.android.library' + id 'org.jetbrains.kotlin.android' + id "org.jetbrains.kotlin.plugin.compose" + id 'kotlin-parcelize' +} + +android { + compileSdk 34 + + defaultConfig { + minSdk 24 + targetSdk 34 + + testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" + consumerProguardFiles "consumer-rules.pro" + } + + namespace 'org.openedx.downloads' + + buildTypes { + release { + minifyEnabled true + proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro' + } + } + + compileOptions { + sourceCompatibility JavaVersion.VERSION_17 + targetCompatibility JavaVersion.VERSION_17 + } + kotlinOptions { + jvmTarget = JavaVersion.VERSION_17 + freeCompilerArgs = List.of("-Xstring-concat=inline") + } + + buildFeatures { + viewBinding true + compose true + } + + flavorDimensions += "env" + productFlavors { + prod { + dimension 'env' + } + develop { + dimension 'env' + } + stage { + dimension 'env' + } + } +} + +dependencies { + implementation project(path: ':core') + + androidTestImplementation 'androidx.test.ext:junit:1.2.1' + androidTestImplementation 'androidx.test.espresso:espresso-core:3.6.1' + testImplementation "junit:junit:$junit_version" + testImplementation "io.mockk:mockk:$mockk_version" + testImplementation "io.mockk:mockk-android:$mockk_version" + testImplementation "androidx.arch.core:core-testing:$android_arch_version" +} diff --git a/downloads/consumer-rules.pro b/downloads/consumer-rules.pro new file mode 100644 index 000000000..e69de29bb diff --git a/downloads/proguard-rules.pro b/downloads/proguard-rules.pro new file mode 100644 index 000000000..cdb308aa0 --- /dev/null +++ b/downloads/proguard-rules.pro @@ -0,0 +1,7 @@ +# Prevent shrinking, optimization, and obfuscation of the library when consumed by other modules. +# This ensures that all classes and methods remain available for use by the consumer of the library. +# Disabling these steps at the library level is important because the main app module will handle +# shrinking, optimization, and obfuscation for the entire application, including this library. +-dontshrink +-dontoptimize +-dontobfuscate diff --git a/downloads/src/main/AndroidManifest.xml b/downloads/src/main/AndroidManifest.xml new file mode 100644 index 000000000..e10007615 --- /dev/null +++ b/downloads/src/main/AndroidManifest.xml @@ -0,0 +1,4 @@ + + + + diff --git a/downloads/src/main/java/org/openedx/downloads/data/repository/DownloadRepository.kt b/downloads/src/main/java/org/openedx/downloads/data/repository/DownloadRepository.kt new file mode 100644 index 000000000..3a23f8118 --- /dev/null +++ b/downloads/src/main/java/org/openedx/downloads/data/repository/DownloadRepository.kt @@ -0,0 +1,56 @@ +package org.openedx.downloads.data.repository + +import kotlinx.coroutines.flow.flow +import org.openedx.core.data.api.CourseApi +import org.openedx.core.data.storage.CorePreferences +import org.openedx.core.data.storage.CourseDao +import org.openedx.core.domain.model.CourseStructure +import org.openedx.core.exception.NoCachedDataException +import org.openedx.core.module.db.DownloadDao + +class DownloadRepository( + private val api: CourseApi, + private val dao: DownloadDao, + private val courseDao: CourseDao, + private val corePreferences: CorePreferences, +) { + fun getDownloadCoursesPreview(refresh: Boolean) = flow { + if (!refresh) { + val cachedDownloadCoursesPreview = dao.getDownloadCoursesPreview() + emit(cachedDownloadCoursesPreview.map { it.mapToDomain() }) + } + val username = corePreferences.user?.username ?: "" + val response = api.getDownloadCoursesPreview(username) + val downloadCoursesPreview = response.map { it.mapToDomain() } + emit(downloadCoursesPreview) + val downloadCoursesPreviewEntity = response.map { it.mapToRoomEntity() } + dao.insertDownloadCoursePreview(downloadCoursesPreviewEntity) + } + + suspend fun getCourseStructureFromCache(courseId: String): CourseStructure { + val cachedCourseStructure = courseDao.getCourseStructureById(courseId) + if (cachedCourseStructure != null) { + return cachedCourseStructure.mapToDomain() + } else { + throw NoCachedDataException() + } + } + + suspend fun getCourseStructure(courseId: String): CourseStructure { + try { + val response = api.getCourseStructure( + cacheControlHeaderParam = "stale-if-error=0", + blocksApiVersion = "v4", + username = corePreferences.user?.username, + courseId = courseId + ) + courseDao.insertCourseStructureEntity(response.mapToRoomEntity()) + return response.mapToDomain() + } catch (_: Exception) { + return getCourseStructureFromCache(courseId) + } + } + + suspend fun getDownloadModelsByCourseIds(courseId: String) = + dao.getDownloadModelsByCourseIds(courseId).map { it.mapToDomain() } +} diff --git a/downloads/src/main/java/org/openedx/downloads/domain/interactor/DownloadInteractor.kt b/downloads/src/main/java/org/openedx/downloads/domain/interactor/DownloadInteractor.kt new file mode 100644 index 000000000..6082e7751 --- /dev/null +++ b/downloads/src/main/java/org/openedx/downloads/domain/interactor/DownloadInteractor.kt @@ -0,0 +1,17 @@ +package org.openedx.downloads.domain.interactor + +import org.openedx.downloads.data.repository.DownloadRepository + +class DownloadInteractor( + private val repository: DownloadRepository +) { + fun getDownloadCoursesPreview(refresh: Boolean) = repository.getDownloadCoursesPreview(refresh) + + suspend fun getDownloadModelsByCourseIds(courseId: String) = + repository.getDownloadModelsByCourseIds(courseId) + + suspend fun getCourseStructureFromCache(courseId: String) = + repository.getCourseStructureFromCache(courseId) + + suspend fun getCourseStructure(courseId: String) = repository.getCourseStructure(courseId) +} diff --git a/downloads/src/main/java/org/openedx/downloads/presentation/DownloadsRouter.kt b/downloads/src/main/java/org/openedx/downloads/presentation/DownloadsRouter.kt new file mode 100644 index 000000000..0b6445f19 --- /dev/null +++ b/downloads/src/main/java/org/openedx/downloads/presentation/DownloadsRouter.kt @@ -0,0 +1,14 @@ +package org.openedx.downloads.presentation + +import androidx.fragment.app.FragmentManager + +interface DownloadsRouter { + + fun navigateToSettings(fm: FragmentManager) + + fun navigateToCourseOutline( + fm: FragmentManager, + courseId: String, + courseTitle: String, + ) +} diff --git a/downloads/src/main/java/org/openedx/downloads/presentation/download/DownloadsFragment.kt b/downloads/src/main/java/org/openedx/downloads/presentation/download/DownloadsFragment.kt new file mode 100644 index 000000000..1dc4d1be9 --- /dev/null +++ b/downloads/src/main/java/org/openedx/downloads/presentation/download/DownloadsFragment.kt @@ -0,0 +1,78 @@ +package org.openedx.downloads.presentation.download + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.ViewGroup +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.ui.platform.ComposeView +import androidx.compose.ui.platform.ViewCompositionStrategy +import androidx.fragment.app.Fragment +import org.koin.androidx.viewmodel.ext.android.viewModel +import org.openedx.core.ui.theme.OpenEdXTheme + +class DownloadsFragment : Fragment() { + + private val viewModel by viewModel() + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + lifecycle.addObserver(viewModel) + } + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle?, + ) = ComposeView(requireContext()).apply { + setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed) + setContent { + OpenEdXTheme { + val uiState by viewModel.uiState.collectAsState() + val uiMessage by viewModel.uiMessage.collectAsState(null) + DownloadsScreen( + uiState = uiState, + uiMessage = uiMessage, + apiHostUrl = viewModel.apiHostUrl, + hasInternetConnection = viewModel.hasInternetConnection, + onAction = { action -> + when (action) { + DownloadsViewActions.OpenSettings -> { + viewModel.onSettingsClick(requireActivity().supportFragmentManager) + } + + DownloadsViewActions.SwipeRefresh -> { + viewModel.refreshData() + } + + is DownloadsViewActions.OpenCourse -> { + viewModel.navigateToCourseOutline( + fm = requireActivity().supportFragmentManager, + courseId = action.courseId + ) + } + + is DownloadsViewActions.DownloadCourse -> { + viewModel.downloadCourse( + requireActivity().supportFragmentManager, + action.courseId + ) + } + + is DownloadsViewActions.CancelDownloading -> { + viewModel.cancelDownloading(action.courseId) + } + + is DownloadsViewActions.RemoveDownloads -> { + viewModel.removeDownloads( + requireActivity().supportFragmentManager, + action.courseId + ) + } + } + } + ) + } + } + } +} diff --git a/downloads/src/main/java/org/openedx/downloads/presentation/download/DownloadsScreen.kt b/downloads/src/main/java/org/openedx/downloads/presentation/download/DownloadsScreen.kt new file mode 100644 index 000000000..fafa04f94 --- /dev/null +++ b/downloads/src/main/java/org/openedx/downloads/presentation/download/DownloadsScreen.kt @@ -0,0 +1,570 @@ +package org.openedx.downloads.presentation.download + +import android.content.res.Configuration.ORIENTATION_LANDSCAPE +import androidx.compose.animation.animateContentSize +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.defaultMinSize +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.lazy.LazyColumn +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.items +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.Card +import androidx.compose.material.CircularProgressIndicator +import androidx.compose.material.Divider +import androidx.compose.material.DropdownMenu +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.Text +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Close +import androidx.compose.material.icons.filled.CloudDone +import androidx.compose.material.icons.filled.MoreHoriz +import androidx.compose.material.icons.outlined.CloudDownload +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.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.graphics.Color +import androidx.compose.ui.layout.ContentScale +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.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import coil.compose.AsyncImage +import coil.request.ImageRequest +import org.openedx.core.domain.model.DownloadCoursePreview +import org.openedx.core.module.db.DownloadModel +import org.openedx.core.module.db.DownloadedState +import org.openedx.core.module.db.DownloadedState.LOADING_COURSE_STRUCTURE +import org.openedx.core.ui.HandleUIMessage +import org.openedx.core.ui.IconText +import org.openedx.core.ui.MainToolbar +import org.openedx.core.ui.OfflineModeDialog +import org.openedx.core.ui.OpenEdXButton +import org.openedx.core.ui.OpenEdXDropdownMenuItem +import org.openedx.core.ui.crop +import org.openedx.core.ui.displayCutoutForLandscape +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.downloads.R +import org.openedx.foundation.extension.toFileSize +import org.openedx.foundation.extension.toImageLink +import org.openedx.foundation.presentation.UIMessage +import org.openedx.foundation.presentation.rememberWindowSize +import org.openedx.foundation.presentation.windowSizeValue + +@OptIn(ExperimentalMaterialApi::class) +@Composable +fun DownloadsScreen( + uiState: DownloadsUIState, + uiMessage: UIMessage?, + apiHostUrl: String, + hasInternetConnection: Boolean, + onAction: (DownloadsViewActions) -> Unit, +) { + val scaffoldState = rememberScaffoldState() + val windowSize = rememberWindowSize() + val configuration = LocalConfiguration.current + val contentWidth by remember(key1 = windowSize) { + mutableStateOf( + windowSize.windowSizeValue( + expanded = Modifier.widthIn(Dp.Unspecified, 560.dp), + compact = Modifier.fillMaxWidth(), + ) + ) + } + val pullRefreshState = rememberPullRefreshState( + refreshing = uiState.isRefreshing, + onRefresh = { onAction(DownloadsViewActions.SwipeRefresh) } + ) + var isInternetConnectionShown by rememberSaveable { + mutableStateOf(false) + } + + Scaffold( + scaffoldState = scaffoldState, + modifier = Modifier + .fillMaxSize(), + backgroundColor = MaterialTheme.appColors.background, + topBar = { + MainToolbar( + modifier = Modifier + .statusBarsInset() + .displayCutoutForLandscape(), + label = stringResource(id = R.string.downloads), + onSettingsClick = { + onAction(DownloadsViewActions.OpenSettings) + } + ) + }, + content = { paddingValues -> + Box( + modifier = Modifier + .fillMaxSize() + .pullRefresh(pullRefreshState) + ) { + if (uiState.isLoading) { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center + ) { + CircularProgressIndicator(color = MaterialTheme.appColors.primary) + } + } else if (uiState.downloadCoursePreviews.isEmpty()) { + EmptyState( + modifier = Modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()) + ) + } else { + Box( + modifier = Modifier + .fillMaxSize() + .displayCutoutForLandscape() + .padding(paddingValues) + .padding(horizontal = 16.dp), + contentAlignment = Alignment.TopCenter + ) { + if (configuration.orientation == ORIENTATION_LANDSCAPE || windowSize.isTablet) { + LazyVerticalGrid( + modifier = contentWidth.fillMaxHeight(), + state = rememberLazyGridState(), + columns = GridCells.Fixed(2), + verticalArrangement = Arrangement.spacedBy(20.dp), + horizontalArrangement = Arrangement.spacedBy(20.dp), + contentPadding = PaddingValues(bottom = 46.dp, top = 12.dp), + content = { + items(uiState.downloadCoursePreviews) { item -> + val downloadModels = + uiState.downloadModels.filter { it.courseId == item.id } + val downloadState = uiState.courseDownloadState[item.id] + ?: DownloadedState.NOT_DOWNLOADED + CourseItem( + modifier = Modifier.height(314.dp), + downloadCoursePreview = item, + downloadModels = downloadModels, + downloadedState = downloadState, + apiHostUrl = apiHostUrl, + onCourseClick = { + onAction(DownloadsViewActions.OpenCourse(item.id)) + }, + onDownloadClick = { + onAction(DownloadsViewActions.DownloadCourse(item.id)) + }, + onCancelClick = { + onAction(DownloadsViewActions.CancelDownloading(item.id)) + }, + onRemoveClick = { + onAction(DownloadsViewActions.RemoveDownloads(item.id)) + } + ) + } + } + ) + } else { + LazyColumn( + modifier = contentWidth, + contentPadding = PaddingValues(bottom = 46.dp, top = 12.dp), + verticalArrangement = Arrangement.spacedBy(20.dp) + ) { + items(uiState.downloadCoursePreviews) { item -> + val downloadModels = + uiState.downloadModels.filter { it.courseId == item.id } + val downloadState = uiState.courseDownloadState[item.id] + ?: DownloadedState.NOT_DOWNLOADED + CourseItem( + downloadCoursePreview = item, + downloadModels = downloadModels, + downloadedState = downloadState, + apiHostUrl = apiHostUrl, + onCourseClick = { + onAction(DownloadsViewActions.OpenCourse(item.id)) + }, + onDownloadClick = { + onAction(DownloadsViewActions.DownloadCourse(item.id)) + }, + onCancelClick = { + onAction(DownloadsViewActions.CancelDownloading(item.id)) + }, + onRemoveClick = { + onAction(DownloadsViewActions.RemoveDownloads(item.id)) + } + ) + } + } + } + } + } + + HandleUIMessage(uiMessage = uiMessage, scaffoldState = scaffoldState) + + PullRefreshIndicator( + uiState.isRefreshing, + pullRefreshState, + Modifier.align(Alignment.TopCenter) + ) + + if (!isInternetConnectionShown && !hasInternetConnection) { + OfflineModeDialog( + Modifier + .fillMaxWidth() + .align(Alignment.BottomCenter), + onDismissCLick = { + isInternetConnectionShown = true + }, + onReloadClick = { + isInternetConnectionShown = true + onAction(DownloadsViewActions.SwipeRefresh) + } + ) + } + } + } + ) +} + +@OptIn(ExperimentalMaterialApi::class) +@Composable +private fun CourseItem( + modifier: Modifier = Modifier, + downloadCoursePreview: DownloadCoursePreview, + downloadModels: List, + downloadedState: DownloadedState, + apiHostUrl: String, + onCourseClick: () -> Unit, + onDownloadClick: () -> Unit, + onRemoveClick: () -> Unit, + onCancelClick: () -> Unit +) { + val windowSize = rememberWindowSize() + val configuration = LocalConfiguration.current + var isDropdownExpanded by remember { mutableStateOf(false) } + val downloadedSize = downloadModels + .filter { it.downloadedState == DownloadedState.DOWNLOADED } + .sumOf { it.size } + val availableSize = downloadCoursePreview.totalSize - downloadedSize + val availableSizeString = availableSize.toFileSize(space = false, round = 1) + val progress: Float = try { + downloadedSize.toFloat() / downloadCoursePreview.totalSize.toFloat() + } catch (_: ArithmeticException) { + 0f + } + Card( + modifier = modifier + .fillMaxWidth(), + backgroundColor = MaterialTheme.appColors.background, + shape = MaterialTheme.appShapes.courseImageShape, + elevation = 4.dp, + onClick = onCourseClick + ) { + Box { + Column( + modifier = Modifier.animateContentSize() + ) { + val imageModifier = + if (configuration.orientation == ORIENTATION_LANDSCAPE || windowSize.isTablet) { + Modifier.weight(1f) + } else { + Modifier.height(120.dp) + } + AsyncImage( + modifier = imageModifier.fillMaxWidth(), + model = ImageRequest.Builder(LocalContext.current) + .data(downloadCoursePreview.image.toImageLink(apiHostUrl)) + .error(org.openedx.core.R.drawable.core_no_image_course) + .placeholder(org.openedx.core.R.drawable.core_no_image_course) + .build(), + contentDescription = null, + contentScale = ContentScale.Crop, + ) + Column( + modifier = Modifier + .padding(horizontal = 12.dp) + .padding(top = 8.dp, bottom = 12.dp), + ) { + Text( + text = downloadCoursePreview.name, + style = MaterialTheme.appTypography.titleLarge, + color = MaterialTheme.appColors.textDark, + overflow = TextOverflow.Ellipsis, + minLines = 1, + maxLines = 2 + ) + Spacer(modifier = Modifier.height(8.dp)) + if (downloadedState != DownloadedState.DOWNLOADED && downloadedSize != 0L) { + LinearProgressIndicator( + modifier = Modifier + .fillMaxWidth() + .height(8.dp) + .clip(CircleShape), + progress = progress, + color = MaterialTheme.appColors.successGreen, + backgroundColor = MaterialTheme.appColors.divider + ) + } + if (downloadedSize != 0L) { + Spacer(modifier = Modifier.height(4.dp)) + IconText( + icon = Icons.Filled.CloudDone, + color = MaterialTheme.appColors.successGreen, + text = stringResource( + R.string.downloaded_downloaded_size, + downloadedSize.toFileSize(space = false, round = 1) + ) + ) + } + if (downloadedState != DownloadedState.DOWNLOADED) { + Spacer(modifier = Modifier.height(4.dp)) + IconText( + icon = Icons.Outlined.CloudDownload, + color = MaterialTheme.appColors.textPrimaryVariant, + text = stringResource( + R.string.downloaded_available_size, + availableSizeString + ) + ) + } + Spacer(modifier = Modifier.height(8.dp)) + if (downloadedState.isWaitingOrDownloading) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.Center, + verticalAlignment = Alignment.CenterVertically + ) { + Box(contentAlignment = Alignment.Center) { + CircularProgressIndicator( + modifier = Modifier.size(36.dp), + backgroundColor = Color.LightGray, + strokeWidth = 2.dp, + color = MaterialTheme.appColors.primary + ) + IconButton( + modifier = Modifier + .size(28.dp) + .padding(2.dp), + onClick = onCancelClick + ) { + Icon( + imageVector = Icons.Filled.Close, + contentDescription = stringResource( + id = R.string.downloads_accessibility_stop_downloading_course + ), + tint = MaterialTheme.appColors.error + ) + } + } + Spacer(modifier = Modifier.width(8.dp)) + val text = if (downloadedState == LOADING_COURSE_STRUCTURE) { + stringResource(R.string.downloads_loading_course_structure) + } else { + stringResource(org.openedx.core.R.string.core_downloading) + } + Text( + text = text, + style = MaterialTheme.appTypography.titleSmall, + color = MaterialTheme.appColors.textPrimary + ) + } + } else if (downloadedState == DownloadedState.NOT_DOWNLOADED) { + OpenEdXButton( + onClick = { + onDownloadClick() + }, + content = { + IconText( + text = stringResource(R.string.downloads_download_course), + icon = Icons.Outlined.CloudDownload, + color = MaterialTheme.appColors.primaryButtonText, + textStyle = MaterialTheme.appTypography.labelLarge + ) + } + ) + } + } + } + + Column( + modifier = Modifier + .align(Alignment.TopEnd), + ) { + if (downloadedSize != 0L || downloadedState.isWaitingOrDownloading) { + MoreButton( + onClick = { + isDropdownExpanded = true + } + ) + } + DropdownMenu( + modifier = Modifier + .crop(vertical = 8.dp) + .defaultMinSize(minWidth = 269.dp) + .background(MaterialTheme.appColors.background), + expanded = isDropdownExpanded, + onDismissRequest = { isDropdownExpanded = false }, + ) { + Column { + if (downloadedSize != 0L) { + OpenEdXDropdownMenuItem( + text = stringResource(R.string.downloads_remove_course_downloads), + onClick = { + isDropdownExpanded = false + onRemoveClick() + } + ) + Divider( + modifier = Modifier.padding(horizontal = 16.dp), + color = MaterialTheme.appColors.divider + ) + } + if (downloadedState.isWaitingOrDownloading) { + OpenEdXDropdownMenuItem( + text = stringResource(R.string.downloads_cancel_download), + onClick = { + isDropdownExpanded = false + onCancelClick() + } + ) + } + } + } + } + } + } +} + +@Composable +private fun MoreButton( + modifier: Modifier = Modifier, + onClick: () -> Unit +) { + IconButton( + modifier = modifier, + onClick = onClick + ) { + Icon( + modifier = Modifier + .size(30.dp) + .background( + color = MaterialTheme.appColors.onPrimary.copy(alpha = 0.5f), + shape = CircleShape + ) + .padding(4.dp), + imageVector = Icons.Default.MoreHoriz, + contentDescription = null, + tint = MaterialTheme.appColors.onSurface + ) + } +} + +@Composable +private fun EmptyState( + modifier: Modifier = Modifier +) { + Box( + modifier = modifier, + contentAlignment = Alignment.Center + ) { + Column( + modifier = Modifier.width(200.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Icon( + painter = painterResource(id = org.openedx.core.R.drawable.core_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.downloads_empty_state_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.downloads_empty_state_description), + color = MaterialTheme.appColors.textDark, + style = MaterialTheme.appTypography.labelMedium, + textAlign = TextAlign.Center + ) + } + } +} + +@Preview +@Composable +private fun DownloadsScreenPreview() { + OpenEdXTheme { + DownloadsScreen( + uiState = DownloadsUIState(isLoading = false), + uiMessage = null, + apiHostUrl = "", + hasInternetConnection = true, + onAction = {} + ) + } +} + +@Preview +@Composable +private fun CourseItemPreview() { + OpenEdXTheme { + CourseItem( + downloadCoursePreview = DownloadCoursePreview("", "name", "", 100), + downloadModels = emptyList(), + apiHostUrl = "", + downloadedState = DownloadedState.NOT_DOWNLOADED, + onCourseClick = {}, + onDownloadClick = {}, + onCancelClick = {}, + onRemoveClick = {}, + ) + } +} diff --git a/downloads/src/main/java/org/openedx/downloads/presentation/download/DownloadsUIState.kt b/downloads/src/main/java/org/openedx/downloads/presentation/download/DownloadsUIState.kt new file mode 100644 index 000000000..e3f24b666 --- /dev/null +++ b/downloads/src/main/java/org/openedx/downloads/presentation/download/DownloadsUIState.kt @@ -0,0 +1,13 @@ +package org.openedx.downloads.presentation.download + +import org.openedx.core.domain.model.DownloadCoursePreview +import org.openedx.core.module.db.DownloadModel +import org.openedx.core.module.db.DownloadedState + +data class DownloadsUIState( + val isLoading: Boolean = true, + val isRefreshing: Boolean = false, + val downloadCoursePreviews: List = emptyList(), + val downloadModels: List = emptyList(), + val courseDownloadState: Map = emptyMap(), +) diff --git a/downloads/src/main/java/org/openedx/downloads/presentation/download/DownloadsViewModel.kt b/downloads/src/main/java/org/openedx/downloads/presentation/download/DownloadsViewModel.kt new file mode 100644 index 000000000..bfa1037ef --- /dev/null +++ b/downloads/src/main/java/org/openedx/downloads/presentation/download/DownloadsViewModel.kt @@ -0,0 +1,386 @@ +package org.openedx.downloads.presentation.download + +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.School +import androidx.fragment.app.FragmentManager +import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.catch +import kotlinx.coroutines.flow.onCompletion +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import org.openedx.core.BlockType +import org.openedx.core.R +import org.openedx.core.config.Config +import org.openedx.core.data.storage.CorePreferences +import org.openedx.core.domain.model.CourseStructure +import org.openedx.core.domain.model.DownloadCoursePreview +import org.openedx.core.module.DownloadWorkerController +import org.openedx.core.module.db.DownloadDao +import org.openedx.core.module.db.DownloadedState +import org.openedx.core.module.download.BaseDownloadViewModel +import org.openedx.core.module.download.DownloadHelper +import org.openedx.core.presentation.CoreAnalytics +import org.openedx.core.presentation.DownloadsAnalytics +import org.openedx.core.presentation.DownloadsAnalyticsEvent +import org.openedx.core.presentation.DownloadsAnalyticsKey +import org.openedx.core.presentation.dialog.downloaddialog.DownloadDialogItem +import org.openedx.core.presentation.dialog.downloaddialog.DownloadDialogManager +import org.openedx.core.system.connection.NetworkConnection +import org.openedx.core.system.notifier.CourseDashboardUpdate +import org.openedx.core.system.notifier.CourseNotifier +import org.openedx.core.system.notifier.CourseStructureGot +import org.openedx.core.system.notifier.CourseStructureUpdated +import org.openedx.core.system.notifier.DiscoveryNotifier +import org.openedx.downloads.domain.interactor.DownloadInteractor +import org.openedx.downloads.presentation.DownloadsRouter +import org.openedx.foundation.extension.isInternetError +import org.openedx.foundation.presentation.UIMessage +import org.openedx.foundation.system.ResourceManager +import org.openedx.foundation.utils.FileUtil + +class DownloadsViewModel( + private val downloadsRouter: DownloadsRouter, + private val networkConnection: NetworkConnection, + private val interactor: DownloadInteractor, + private val downloadDialogManager: DownloadDialogManager, + private val resourceManager: ResourceManager, + private val fileUtil: FileUtil, + private val config: Config, + private val analytics: DownloadsAnalytics, + private val discoveryNotifier: DiscoveryNotifier, + private val courseNotifier: CourseNotifier, + private val router: DownloadsRouter, + preferencesManager: CorePreferences, + coreAnalytics: CoreAnalytics, + downloadDao: DownloadDao, + workerController: DownloadWorkerController, + downloadHelper: DownloadHelper, +) : BaseDownloadViewModel( + downloadDao, + preferencesManager, + workerController, + coreAnalytics, + downloadHelper, +) { + val apiHostUrl get() = config.getApiHostURL() + + private val _uiState = MutableStateFlow(DownloadsUIState()) + val uiState: StateFlow = _uiState.asStateFlow() + + private val _uiMessage = MutableSharedFlow() + val uiMessage: SharedFlow = _uiMessage.asSharedFlow() + + private val courseBlockIds = mutableMapOf>() + + val hasInternetConnection: Boolean get() = networkConnection.isOnline() + + private var downloadJobs = mutableMapOf() + + init { + fetchDownloads(refresh = false) + observeCourseDashboardUpdates() + observeDownloadingModels() + observeDownloadModelsStatus() + observeCourseStructureUpdates() + } + + private fun observeCourseDashboardUpdates() { + viewModelScope.launch { + discoveryNotifier.notifier.collect { notifier -> + if (notifier is CourseDashboardUpdate) { + fetchDownloads(refresh = true) + } + } + } + } + + private fun observeCourseStructureUpdates() { + viewModelScope.launch { + courseNotifier.notifier.collect { notifier -> + when (notifier) { + is CourseStructureGot, is CourseStructureUpdated -> { + fetchDownloads(refresh = true) + } + } + } + } + } + + private fun observeDownloadingModels() { + viewModelScope.launch { + downloadingModelsFlow.collect { downloadModels -> + _uiState.update { state -> + state.copy(downloadModels = downloadModels) + } + } + } + } + + private fun observeDownloadModelsStatus() { + viewModelScope.launch { + downloadModelsStatusFlow.collect { statusMap -> + val updatedCourseStates = courseBlockIds.mapValues { (courseId, blockIds) -> + val currentCourseState = uiState.value.courseDownloadState[courseId] + val blockStates = blockIds.mapNotNull { statusMap[it] } + val computedState = if (blockStates.isEmpty()) { + DownloadedState.NOT_DOWNLOADED + } else { + val downloadedSize = _uiState.value.downloadModels + .filter { it.courseId == courseId } + .sumOf { it.size } + val courseSize = _uiState.value.downloadCoursePreviews + .find { it.id == courseId }?.totalSize ?: 0 + val isSizeMatch: Boolean = + downloadedSize.toDouble() / courseSize >= SIZE_MATCH_THRESHOLD + determineCourseState(blockStates, isSizeMatch) + } + if (currentCourseState == DownloadedState.LOADING_COURSE_STRUCTURE && + computedState == DownloadedState.NOT_DOWNLOADED + ) { + DownloadedState.LOADING_COURSE_STRUCTURE + } else { + computedState + } + } + + _uiState.update { state -> + state.copy(courseDownloadState = updatedCourseStates) + } + } + } + } + + private fun determineCourseState( + blockStates: List, + isSizeMatch: Boolean + ): DownloadedState { + return when { + blockStates.all { it == DownloadedState.DOWNLOADED } && isSizeMatch -> DownloadedState.DOWNLOADED + blockStates.all { it == DownloadedState.WAITING } -> DownloadedState.WAITING + blockStates.any { it == DownloadedState.DOWNLOADING } -> DownloadedState.DOWNLOADING + else -> DownloadedState.NOT_DOWNLOADED + } + } + + private fun fetchDownloads(refresh: Boolean) { + viewModelScope.launch(Dispatchers.IO) { + updateLoadingState(isLoading = !refresh, isRefreshing = refresh) + interactor.getDownloadCoursesPreview(refresh) + .onCompletion { + resetLoadingState() + } + .catch { e -> + emitErrorMessage(e) + } + .collect { downloadCoursePreviews -> + downloadCoursePreviews.forEach { preview -> + runCatching { initializeCourseBlocks(preview.id, useCache = true) } + .onFailure { it.printStackTrace() } + } + allBlocks.values + .filter { it.type == BlockType.SEQUENTIAL } + .forEach { addDownloadableChildrenForSequentialBlock(it) } + initDownloadModelsStatus() + _uiState.update { state -> + state.copy( + downloadCoursePreviews = downloadCoursePreviews, + isLoading = false, + isRefreshing = false + ) + } + } + } + } + + private fun updateLoadingState(isLoading: Boolean, isRefreshing: Boolean) { + _uiState.update { state -> + state.copy(isLoading = isLoading, isRefreshing = isRefreshing) + } + } + + private fun emitErrorMessage(e: Throwable) { + viewModelScope.launch { + val text = if (e.isInternetError()) { + R.string.core_error_no_connection + } else { + R.string.core_error_unknown_error + } + _uiMessage.emit( + UIMessage.SnackBarMessage(resourceManager.getString(text)) + ) + } + } + + fun refreshData() { + fetchDownloads(refresh = true) + } + + fun onSettingsClick(fragmentManager: FragmentManager) { + downloadsRouter.navigateToSettings(fragmentManager) + } + + fun downloadCourse(fragmentManager: FragmentManager, courseId: String) { + logEvent(DownloadsAnalyticsEvent.DOWNLOAD_COURSE_CLICKED) + try { + showDownloadPopup(fragmentManager, courseId) + } catch (e: Exception) { + logEvent(DownloadsAnalyticsEvent.DOWNLOAD_ERROR) + updateCourseState(courseId, DownloadedState.NOT_DOWNLOADED) + emitErrorMessage(e) + } + } + + fun cancelDownloading(courseId: String) { + logEvent(DownloadsAnalyticsEvent.CANCEL_DOWNLOAD_CLICKED) + viewModelScope.launch { + downloadJobs[courseId]?.cancel() + interactor.getDownloadModelsByCourseIds(courseId) + .filter { it.downloadedState.isWaitingOrDownloading } + .forEach { removeBlockDownloadModel(it.id) } + } + } + + fun removeDownloads(fragmentManager: FragmentManager, courseId: String) { + logEvent(DownloadsAnalyticsEvent.REMOVE_DOWNLOAD_CLICKED) + viewModelScope.launch { + val downloadModels = interactor.getDownloadModelsByCourseIds(courseId) + val downloadedModels = downloadModels.filter { + it.downloadedState == DownloadedState.DOWNLOADED + } + val totalSize = downloadedModels.sumOf { it.size } + val title = getCoursePreview(courseId)?.name.orEmpty() + val downloadDialogItem = DownloadDialogItem( + title = title, + size = totalSize, + icon = Icons.Default.School + ) + downloadDialogManager.showRemoveDownloadModelPopup( + downloadDialogItem = downloadDialogItem, + fragmentManager = fragmentManager, + removeDownloadModels = { + downloadModels.forEach { super.removeBlockDownloadModel(it.id) } + logEvent(DownloadsAnalyticsEvent.DOWNLOAD_REMOVED) + } + ) + } + } + + private suspend fun initializeCourseBlocks( + courseId: String, + useCache: Boolean + ): CourseStructure { + val courseStructure = if (useCache) { + interactor.getCourseStructureFromCache(courseId) + } else { + interactor.getCourseStructure(courseId) + } + courseBlockIds[courseStructure.id] = courseStructure.blockData.map { it.id } + addBlocks(courseStructure.blockData) + return courseStructure + } + + private fun showDownloadPopup(fragmentManager: FragmentManager, courseId: String) { + viewModelScope.launch { + val coursePreview = getCoursePreview(courseId) ?: return@launch + val downloadModels = interactor.getDownloadModelsByCourseIds(courseId) + val downloadedModelsSize = downloadModels + .filter { it.downloadedState == DownloadedState.DOWNLOADED } + .sumOf { it.size } + downloadDialogManager.showPopup( + coursePreview = coursePreview.copy(totalSize = coursePreview.totalSize - downloadedModelsSize), + isBlocksDownloaded = false, + fragmentManager = fragmentManager, + removeDownloadModels = ::removeDownloadModels, + saveDownloadModels = { + initiateSaveDownloadModels(courseId) + }, + onDismissClick = { + logEvent(DownloadsAnalyticsEvent.DOWNLOAD_CANCELLED) + updateCourseState(courseId, DownloadedState.NOT_DOWNLOADED) + }, + onConfirmClick = { + logEvent(DownloadsAnalyticsEvent.DOWNLOAD_CONFIRMED) + } + ) + } + } + + private fun initiateSaveDownloadModels(courseId: String) { + downloadJobs[courseId] = viewModelScope.launch { + try { + updateCourseState(courseId, DownloadedState.LOADING_COURSE_STRUCTURE) + val courseStructure = initializeCourseBlocks(courseId, useCache = false) + courseStructure.blockData + .filter { it.type == BlockType.SEQUENTIAL } + .forEach { sequentialBlock -> + addDownloadableChildrenForSequentialBlock(sequentialBlock) + super.saveDownloadModels( + fileUtil.getExternalAppDir().path, + courseId, + sequentialBlock.id + ) + } + } catch (e: Exception) { + updateCourseState(courseId, DownloadedState.NOT_DOWNLOADED) + emitErrorMessage(e) + } + } + } + + fun navigateToCourseOutline(fm: FragmentManager, courseId: String) { + val coursePreview = getCoursePreview(courseId) ?: return + router.navigateToCourseOutline( + fm = fm, + courseId = coursePreview.id, + courseTitle = coursePreview.name, + ) + } + + private fun logEvent(event: DownloadsAnalyticsEvent) { + analytics.logEvent( + event = event.eventName, + params = mapOf(DownloadsAnalyticsKey.NAME.key to event.biValue) + ) + } + + private fun resetLoadingState() { + _uiState.update { state -> + state.copy(isLoading = false, isRefreshing = false) + } + } + + private fun updateCourseState(courseId: String, state: DownloadedState) { + _uiState.update { currentState -> + currentState.copy( + courseDownloadState = currentState.courseDownloadState.toMutableMap().apply { + put(courseId, state) + } + ) + } + } + + private fun getCoursePreview(courseId: String): DownloadCoursePreview? { + return _uiState.value.downloadCoursePreviews.find { it.id == courseId } + } + + companion object { + const val SIZE_MATCH_THRESHOLD = 0.95 + } +} + +interface DownloadsViewActions { + object OpenSettings : DownloadsViewActions + object SwipeRefresh : DownloadsViewActions + data class OpenCourse(val courseId: String) : DownloadsViewActions + data class DownloadCourse(val courseId: String) : DownloadsViewActions + data class CancelDownloading(val courseId: String) : DownloadsViewActions + data class RemoveDownloads(val courseId: String) : DownloadsViewActions +} diff --git a/downloads/src/main/res/values/strings.xml b/downloads/src/main/res/values/strings.xml new file mode 100644 index 000000000..5a0503db1 --- /dev/null +++ b/downloads/src/main/res/values/strings.xml @@ -0,0 +1,13 @@ + + + Downloads + Download course + Remove course downloads + Cancel download + No Courses with Downloadable Content + You currently have no courses with downloadable content. + %1$s downloaded + %1$s available + Stop downloading course + Loading course structure… + \ No newline at end of file diff --git a/downloads/src/test/java/org/openedx/downloads/DownloadsViewModelTest.kt b/downloads/src/test/java/org/openedx/downloads/DownloadsViewModelTest.kt new file mode 100644 index 000000000..e9476fbcb --- /dev/null +++ b/downloads/src/test/java/org/openedx/downloads/DownloadsViewModelTest.kt @@ -0,0 +1,394 @@ +package org.openedx.downloads + +import androidx.arch.core.executor.testing.InstantTaskExecutorRule +import androidx.fragment.app.FragmentManager +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.every +import io.mockk.mockk +import io.mockk.verify +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.async +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TestRule +import org.openedx.core.BlockType +import org.openedx.core.R +import org.openedx.core.config.Config +import org.openedx.core.data.storage.CorePreferences +import org.openedx.core.domain.model.AssignmentProgress +import org.openedx.core.domain.model.Block +import org.openedx.core.domain.model.BlockCounts +import org.openedx.core.domain.model.CourseStructure +import org.openedx.core.domain.model.CoursewareAccess +import org.openedx.core.domain.model.DownloadCoursePreview +import org.openedx.core.module.DownloadWorkerController +import org.openedx.core.module.db.DownloadDao +import org.openedx.core.module.db.DownloadModel +import org.openedx.core.module.db.DownloadModelEntity +import org.openedx.core.module.db.DownloadedState +import org.openedx.core.module.db.FileType +import org.openedx.core.module.download.DownloadHelper +import org.openedx.core.presentation.CoreAnalytics +import org.openedx.core.presentation.DownloadsAnalytics +import org.openedx.core.presentation.dialog.downloaddialog.DownloadDialogManager +import org.openedx.core.system.connection.NetworkConnection +import org.openedx.core.system.notifier.CourseNotifier +import org.openedx.core.system.notifier.DiscoveryNotifier +import org.openedx.downloads.domain.interactor.DownloadInteractor +import org.openedx.downloads.presentation.DownloadsRouter +import org.openedx.downloads.presentation.download.DownloadsViewModel +import org.openedx.foundation.presentation.UIMessage +import org.openedx.foundation.system.ResourceManager +import org.openedx.foundation.utils.FileUtil +import java.net.UnknownHostException +import java.util.Date + +class DownloadsViewModelTest { + + @get:Rule + val testInstantTaskExecutorRule: TestRule = InstantTaskExecutorRule() + + private val dispatcher = StandardTestDispatcher() + + // Mocks for all dependencies + private val downloadsRouter = mockk(relaxed = true) + private val networkConnection = mockk(relaxed = true) + private val interactor = mockk(relaxed = true) + private val downloadDialogManager = mockk(relaxed = true) + private val resourceManager = mockk(relaxed = true) + private val fileUtil = mockk(relaxed = true) + private val config = mockk(relaxed = true) + private val analytics = mockk(relaxed = true) + private val preferencesManager = mockk(relaxed = true) + private val coreAnalytics = mockk(relaxed = true) + private val downloadDao = mockk(relaxed = true) + private val workerController = mockk(relaxed = true) + private val downloadHelper = mockk(relaxed = true) + private val router = mockk(relaxed = true) + private val discoveryNotifier = mockk(relaxed = true) + private val courseNotifier = mockk(relaxed = true) + + private val noInternet = "No connection" + private val unknownError = "Unknown error" + + private val downloadCoursePreview = + DownloadCoursePreview( + id = "course1", + name = "", + image = "", + totalSize = DownloadDialogManager.MAX_CELLULAR_SIZE.toLong() + ) + private val assignmentProgress = AssignmentProgress( + assignmentType = "Homework", + numPointsEarned = 1f, + numPointsPossible = 3f + ) + private val blocks = listOf( + Block( + id = "id", + blockId = "blockId", + lmsWebUrl = "lmsWebUrl", + legacyWebUrl = "legacyWebUrl", + studentViewUrl = "studentViewUrl", + type = BlockType.CHAPTER, + displayName = "Block", + graded = false, + studentViewData = null, + studentViewMultiDevice = false, + blockCounts = BlockCounts(0), + descendants = listOf("1", "id1"), + descendantsType = BlockType.HTML, + completion = 0.0, + assignmentProgress = assignmentProgress, + due = Date(), + offlineDownload = null, + ), + Block( + id = "id1", + blockId = "blockId", + lmsWebUrl = "lmsWebUrl", + legacyWebUrl = "legacyWebUrl", + studentViewUrl = "studentViewUrl", + type = BlockType.HTML, + displayName = "Block", + graded = false, + studentViewData = null, + studentViewMultiDevice = false, + blockCounts = BlockCounts(0), + descendants = listOf("id2"), + descendantsType = BlockType.HTML, + completion = 0.0, + assignmentProgress = assignmentProgress, + due = Date(), + offlineDownload = null, + ), + Block( + id = "id2", + blockId = "blockId", + lmsWebUrl = "lmsWebUrl", + legacyWebUrl = "legacyWebUrl", + studentViewUrl = "studentViewUrl", + type = BlockType.HTML, + displayName = "Block", + graded = false, + studentViewData = null, + studentViewMultiDevice = false, + blockCounts = BlockCounts(0), + descendants = emptyList(), + descendantsType = BlockType.HTML, + completion = 0.0, + assignmentProgress = assignmentProgress, + due = Date(), + offlineDownload = null, + ) + ) + + private val downloadModel = DownloadModel( + "id", + "title", + "", + 0, + "", + "url", + FileType.VIDEO, + DownloadedState.NOT_DOWNLOADED, + null + ) + + private val courseStructure = CourseStructure( + root = "", + blockData = blocks, + id = "id", + name = "Course name", + number = "", + org = "Org", + start = Date(), + startDisplay = "", + startType = "", + end = Date(), + coursewareAccess = CoursewareAccess( + true, + "", + "", + "", + "", + "" + ), + media = null, + certificate = null, + isSelfPaced = false, + progress = null + ) + + @OptIn(ExperimentalCoroutinesApi::class) + @Before + fun setUp() { + Dispatchers.setMain(dispatcher) + every { config.getApiHostURL() } returns "http://localhost:8000" + every { resourceManager.getString(R.string.core_error_no_connection) } returns noInternet + every { resourceManager.getString(R.string.core_error_unknown_error) } returns unknownError + every { networkConnection.isOnline() } returns true + + coEvery { interactor.getDownloadCoursesPreview(any()) } returns flow { + emit(listOf(downloadCoursePreview)) + } + coEvery { interactor.getCourseStructureFromCache("course1") } returns courseStructure + coEvery { interactor.getCourseStructure("course1") } returns courseStructure + coEvery { interactor.getDownloadModelsByCourseIds(any()) } returns emptyList() + coEvery { downloadDao.getAllDataFlow() } returns flowOf( + listOf( + DownloadModelEntity.createFrom( + downloadModel + ) + ) + ) + } + + @OptIn(ExperimentalCoroutinesApi::class) + @Test + fun `onSettingsClick should navigate to settings`() = runTest { + val viewModel = DownloadsViewModel( + downloadsRouter, + networkConnection, + interactor, + downloadDialogManager, + resourceManager, + fileUtil, + config, + analytics, + discoveryNotifier, + courseNotifier, + router, + preferencesManager, + coreAnalytics, + downloadDao, + workerController, + downloadHelper + ) + advanceUntilIdle() + + val fragmentManager = mockk(relaxed = true) + viewModel.onSettingsClick(fragmentManager) + verify(exactly = 1) { downloadsRouter.navigateToSettings(fragmentManager) } + } + + @OptIn(ExperimentalCoroutinesApi::class) + @Test + fun `downloadCourse should show download dialog`() = runTest { + val viewModel = DownloadsViewModel( + downloadsRouter, + networkConnection, + interactor, + downloadDialogManager, + resourceManager, + fileUtil, + config, + analytics, + discoveryNotifier, + courseNotifier, + router, + preferencesManager, + coreAnalytics, + downloadDao, + workerController, + downloadHelper + ) + val fragmentManager = mockk(relaxed = true) + viewModel.downloadCourse(fragmentManager, "course1") + advanceUntilIdle() + + verify(exactly = 1) { analytics.logEvent(any(), any()) } + + coVerify(exactly = 1) { + downloadDialogManager.showPopup( + coursePreview = any(), + isBlocksDownloaded = any(), + fragmentManager = any(), + removeDownloadModels = any(), + saveDownloadModels = any(), + onDismissClick = any(), + onConfirmClick = any() + ) + } + } + + @OptIn(ExperimentalCoroutinesApi::class) + @Test + fun `cancelDownloading should update courseDownloadState to NOT_DOWNLOADED and cancel download job`() = + runTest { + val viewModel = DownloadsViewModel( + downloadsRouter, + networkConnection, + interactor, + downloadDialogManager, + resourceManager, + fileUtil, + config, + analytics, + discoveryNotifier, + courseNotifier, + router, + preferencesManager, + coreAnalytics, + downloadDao, + workerController, + downloadHelper + ) + advanceUntilIdle() + + val fragmentManager = mockk(relaxed = true) + viewModel.downloadCourse(fragmentManager, "course1") + advanceUntilIdle() + + viewModel.cancelDownloading("course1") + advanceUntilIdle() + + coVerify { interactor.getDownloadModelsByCourseIds(any()) } + } + + @OptIn(ExperimentalCoroutinesApi::class) + @Test + fun `removeDownloads should show remove popup with correct parameters`() = runTest { + coEvery { interactor.getDownloadModelsByCourseIds(any()) } returns listOf(downloadModel) + + val viewModel = DownloadsViewModel( + downloadsRouter, + networkConnection, + interactor, + downloadDialogManager, + resourceManager, + fileUtil, + config, + analytics, + discoveryNotifier, + courseNotifier, + router, + preferencesManager, + coreAnalytics, + downloadDao, + workerController, + downloadHelper + ) + advanceUntilIdle() + + val fragmentManager = mockk(relaxed = true) + viewModel.removeDownloads(fragmentManager, "course1") + advanceUntilIdle() + + coVerify { + downloadDialogManager.showRemoveDownloadModelPopup( + any(), + any(), + any() + ) + } + + verify(exactly = 1) { analytics.logEvent(any(), any()) } + } + + @OptIn(ExperimentalCoroutinesApi::class) + @Test + fun `refreshData no internet error should emit snack bar message`() = runTest { + every { networkConnection.isOnline() } returns true + coEvery { interactor.getDownloadCoursesPreview(any()) } returns flow { throw UnknownHostException() } + + val viewModel = DownloadsViewModel( + downloadsRouter, + networkConnection, + interactor, + downloadDialogManager, + resourceManager, + fileUtil, + config, + analytics, + discoveryNotifier, + courseNotifier, + router, + preferencesManager, + coreAnalytics, + downloadDao, + workerController, + downloadHelper + ) + val deferred = async { viewModel.uiMessage.first() } + advanceUntilIdle() + + viewModel.refreshData() + advanceUntilIdle() + + assertEquals(noInternet, (deferred.await() as? UIMessage.SnackBarMessage)?.message) + assertFalse(viewModel.uiState.value.isRefreshing) + } +} diff --git a/settings.gradle b/settings.gradle index 40beee473..bdb401703 100644 --- a/settings.gradle +++ b/settings.gradle @@ -46,3 +46,4 @@ include ':discovery' include ':profile' include ':discussion' include ':whatsnew' +include ':downloads' From 0156743dd1d4336606fe17e3d973a6619db58935 Mon Sep 17 00:00:00 2001 From: PavloNetrebchuk <141041606+PavloNetrebchuk@users.noreply.github.com> Date: Thu, 3 Apr 2025 16:06:29 +0300 Subject: [PATCH 05/24] Feat: discussion content style (#437) * feat: replaced text with WebView to support rich formatting * feat: html renderer * fix: mapping error * fix: changes according code review * fix: changes according code review --- .../openedx/core/extension/TextConverter.kt | 76 ----- .../java/org/openedx/core/ui/ComposeCommon.kt | 134 -------- .../java/org/openedx/core/ui/HTMLRenderer.kt | 295 ++++++++++++++++++ .../data/model/response/CommentsResponse.kt | 2 - .../data/model/response/ThreadsResponse.kt | 2 - .../domain/model/DiscussionComment.kt | 2 - .../openedx/discussion/domain/model/Thread.kt | 2 - .../comments/DiscussionCommentsFragment.kt | 3 - .../responses/DiscussionResponsesFragment.kt | 33 +- .../search/DiscussionSearchThreadFragment.kt | 2 - .../threads/DiscussionThreadsFragment.kt | 2 - .../presentation/ui/DiscussionUI.kt | 24 +- .../DiscussionCommentsViewModelTest.kt | 3 - .../DiscussionResponsesViewModelTest.kt | 42 --- .../DiscussionSearchThreadViewModelTest.kt | 2 - .../DiscussionAddThreadViewModelTest.kt | 2 - .../threads/DiscussionThreadsViewModelTest.kt | 2 - 17 files changed, 323 insertions(+), 305 deletions(-) create mode 100644 core/src/main/java/org/openedx/core/ui/HTMLRenderer.kt diff --git a/core/src/main/java/org/openedx/core/extension/TextConverter.kt b/core/src/main/java/org/openedx/core/extension/TextConverter.kt index 22879220e..f01d33aa3 100644 --- a/core/src/main/java/org/openedx/core/extension/TextConverter.kt +++ b/core/src/main/java/org/openedx/core/extension/TextConverter.kt @@ -1,8 +1,6 @@ package org.openedx.core.extension -import android.os.Parcelable import android.util.Patterns -import kotlinx.parcelize.Parcelize import org.jsoup.Jsoup import org.jsoup.nodes.Document import org.jsoup.select.Elements @@ -36,84 +34,10 @@ object TextConverter : KoinComponent { return LinkedText(text, linksMap.toMap()) } - fun textToLinkedImageText(html: String): LinkedImageText { - val doc: Document = - Jsoup.parse(html) - val links: Elements = doc.select("a[href]") - var text = doc.text() - val headers = getHeaders(doc) - val linksMap = mutableMapOf() - for (link in links) { - if (isLinkValid(link.attr("href"))) { - val linkText = if (link.hasText()) link.text() else link.attr("href") - linksMap[linkText] = link.attr("href") - } else { - val resultLink = - if (link.attr("href").isNotEmpty() && link.attr("href")[0] == '/') { - link.attr("href").substring(1) - } else { - link.attr("href") - } - if (resultLink.isNotEmpty() && isLinkValid(config.getApiHostURL() + resultLink)) { - linksMap[link.text()] = config.getApiHostURL() + resultLink - } - } - } - text = setSpacesForHeaders(text, headers) - return LinkedImageText( - text, - linksMap.toMap(), - getImageLinks(doc), - headers - ) - } - fun isLinkValid(link: String) = Patterns.WEB_URL.matcher(link.lowercase()).matches() - - @Suppress("MagicNumber") - private fun getHeaders(document: Document): List { - val headersList = mutableListOf() - for (index in 1..6) { - if (document.select("h$index").hasText()) { - headersList.add(document.select("h$index").text()) - } - } - return headersList.toList() - } - - private fun setSpacesForHeaders(text: String, headers: List): String { - var result = text - headers.forEach { - val startIndex = text.indexOf(it) - val endIndex = startIndex + it.length + 1 - result = text.replaceRange(startIndex, endIndex, it + "\n") - } - return result - } - - private fun getImageLinks(document: Document): Map { - val imageLinks = mutableMapOf() - val elements = document.getElementsByTag("img") - for (element in elements) { - if (element.hasAttr("alt")) { - imageLinks[element.attr("alt")] = element.attr("src") - } else { - imageLinks[element.attr("src")] = element.attr("src") - } - } - return imageLinks.toMap() - } } data class LinkedText( val text: String, val links: Map ) - -@Parcelize -data class LinkedImageText( - val text: String, - val links: Map, - val imageLinks: Map, - val headers: List -) : Parcelable 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 9961c2887..3cf6eb1fc 100644 --- a/core/src/main/java/org/openedx/core/ui/ComposeCommon.kt +++ b/core/src/main/java/org/openedx/core/ui/ComposeCommon.kt @@ -1,7 +1,5 @@ package org.openedx.core.ui -import android.os.Build -import android.os.Build.VERSION.SDK_INT import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.background @@ -19,7 +17,6 @@ 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.heightIn import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width @@ -81,7 +78,6 @@ import androidx.compose.ui.graphics.painter.Painter import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.graphics.vector.rememberVectorPainter import androidx.compose.ui.input.pointer.pointerInput -import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalFocusManager import androidx.compose.ui.platform.LocalSoftwareKeyboardController @@ -106,15 +102,10 @@ import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.TextUnit import androidx.compose.ui.unit.dp import androidx.compose.ui.zIndex -import coil.ImageLoader -import coil.compose.AsyncImage -import coil.decode.GifDecoder -import coil.decode.ImageDecoderDecoder import kotlinx.coroutines.launch import org.openedx.core.NoContentScreenType import org.openedx.core.R import org.openedx.core.domain.model.RegistrationField -import org.openedx.core.extension.LinkedImageText import org.openedx.core.presentation.global.ErrorType import org.openedx.core.ui.theme.OpenEdXTheme import org.openedx.core.ui.theme.appColors @@ -542,131 +533,6 @@ fun HyperlinkText( ) } -@Composable -fun HyperlinkImageText( - modifier: Modifier = Modifier, - title: String = "", - imageText: LinkedImageText, - textStyle: TextStyle = TextStyle.Default, - linkTextColor: Color = MaterialTheme.appColors.primary, - linkTextFontWeight: FontWeight = FontWeight.Normal, - linkTextDecoration: TextDecoration = TextDecoration.None, - fontSize: TextUnit = TextUnit.Unspecified, -) { - val fullText = imageText.text - val hyperLinks = imageText.links - val annotatedString = buildAnnotatedString { - if (title.isNotEmpty()) { - append(title) - append("\n\n") - } - append(fullText) - addStyle( - style = SpanStyle( - color = MaterialTheme.appColors.textPrimary, - fontSize = fontSize - ), - start = 0, - end = this.length - ) - - for ((key, value) in hyperLinks) { - val startIndex = this.toString().indexOf(key) - if (startIndex == -1) continue - val endIndex = startIndex + key.length - addStyle( - style = SpanStyle( - color = linkTextColor, - fontSize = fontSize, - fontWeight = linkTextFontWeight, - textDecoration = linkTextDecoration - ), - start = startIndex, - end = endIndex - ) - addStringAnnotation( - tag = "URL", - annotation = value, - start = startIndex, - end = endIndex - ) - } - if (title.isNotEmpty()) { - addStyle( - style = SpanStyle( - color = MaterialTheme.appColors.textPrimary, - fontSize = MaterialTheme.appTypography.titleLarge.fontSize, - fontWeight = MaterialTheme.appTypography.titleLarge.fontWeight - ), - start = 0, - end = title.length - ) - } - for (item in imageText.headers) { - val startIndex = this.toString().indexOf(item) - if (startIndex == -1) continue - val endIndex = startIndex + item.length - addStyle( - style = SpanStyle( - color = MaterialTheme.appColors.textPrimary, - fontSize = MaterialTheme.appTypography.titleLarge.fontSize, - fontWeight = MaterialTheme.appTypography.titleLarge.fontWeight - ), - start = startIndex, - end = endIndex - ) - } - addStyle( - style = SpanStyle( - fontSize = fontSize - ), - start = 0, - end = this.length - ) - } - - val uriHandler = LocalUriHandler.current - val context = LocalContext.current - val imageLoader = ImageLoader.Builder(context) - .components { - if (SDK_INT >= Build.VERSION_CODES.P) { - add(ImageDecoderDecoder.Factory()) - } else { - add(GifDecoder.Factory()) - } - } - .build() - - Column(Modifier.fillMaxWidth()) { - BasicText( - text = annotatedString, - modifier = modifier.pointerInput(Unit) { - detectTapGestures { offset -> - val position = offset.x.toInt() - annotatedString.getStringAnnotations("URL", position, position) - .firstOrNull()?.let { stringAnnotation -> - uriHandler.openUri(stringAnnotation.item) - } - } - }, - style = textStyle - ) - imageText.imageLinks.values.forEach { - Spacer(Modifier.height(8.dp)) - AsyncImage( - modifier = Modifier - .fillMaxWidth() - .heightIn(0.dp, 360.dp), - contentScale = ContentScale.Fit, - model = it, - contentDescription = null, - imageLoader = imageLoader - ) - } - Spacer(Modifier.height(16.dp)) - } -} - @Composable fun SheetContent( searchValue: TextFieldValue, diff --git a/core/src/main/java/org/openedx/core/ui/HTMLRenderer.kt b/core/src/main/java/org/openedx/core/ui/HTMLRenderer.kt new file mode 100644 index 000000000..0105e2cff --- /dev/null +++ b/core/src/main/java/org/openedx/core/ui/HTMLRenderer.kt @@ -0,0 +1,295 @@ +package org.openedx.core.ui + +import android.content.ActivityNotFoundException +import android.content.Intent +import androidx.compose.foundation.background +import androidx.compose.foundation.gestures.detectTapGestures +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.IntrinsicSize +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.material.MaterialTheme +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.TextLayoutResult +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontStyle +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextDecoration +import androidx.compose.ui.text.withStyle +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.times +import androidx.core.net.toUri +import coil.compose.AsyncImage +import coil.request.ImageRequest +import org.jsoup.Jsoup +import org.jsoup.nodes.Element +import org.jsoup.nodes.Node +import org.jsoup.nodes.TextNode +import org.openedx.core.R +import org.openedx.core.ui.theme.appColors + +@Composable +fun RenderHtmlContent(html: String) { + val document = remember(html) { Jsoup.parse(html) } + val bodyElements = document.body().children() + Column { + bodyElements.forEach { element -> + RenderBlockElement(element) + } + } +} + +@Composable +private fun RenderClickableText(annotated: AnnotatedString) { + val context = LocalContext.current + val hasLink = annotated.getStringAnnotations("URL", 0, annotated.length).isNotEmpty() + var textLayoutResult by remember { mutableStateOf(null) } + val modifier = if (hasLink) { + Modifier.pointerInput(annotated) { + detectTapGestures { offset -> + textLayoutResult?.let { layoutResult -> + val position = layoutResult.getOffsetForPosition(offset) + annotated.getStringAnnotations("URL", position, position) + .firstOrNull()?.let { annotation -> + try { + val intent = Intent(Intent.ACTION_VIEW, annotation.item.toUri()) + context.startActivity(intent) + } catch (e: ActivityNotFoundException) { + e.printStackTrace() + } + } + } + } + } + } else { + Modifier + } + Text( + text = annotated, + modifier = modifier, + color = MaterialTheme.appColors.textPrimary, + onTextLayout = { textLayoutResult = it } + ) +} + +@Composable +private fun RenderParagraph(element: Element) { + val segments = extractSegmentsFromNodes(element.childNodes()) + Column(modifier = Modifier.padding(vertical = 4.dp)) { + segments.forEach { segment -> + when (segment) { + is List<*> -> { + val nodes = segment.filterIsInstance() + val annotated = buildAnnotatedStringFromNodes(nodes) + RenderClickableText(annotated) + } + + is Element -> { + RenderBlockElement(segment) + } + } + } + } +} + +private fun extractSegmentsFromNodes(nodes: List): List { + val segments = mutableListOf() + val currentSegment = mutableListOf() + + for (node in nodes) { + if (node is Element) { + val tagName = node.tagName() + if (tagName == "img" || tagName == "ul" || tagName == "ol" || tagName == "blockquote") { + flush(currentSegment, segments) + segments.add(node) + } else if (node.select("img").isNotEmpty()) { + flush(currentSegment, segments) + segments.addAll(extractSegmentsFromNodes(node.childNodes())) + } else { + currentSegment.add(node) + } + } else { + currentSegment.add(node) + } + } + flush(currentSegment, segments) + return segments +} + +@Composable +private fun RenderBlockElement(element: Element, indent: Int = 0) { + when (element.tagName()) { + "p" -> { + RenderParagraph(element) + } + + "ul" -> { + Column(modifier = Modifier.padding(start = (indent + 1) * 16.dp)) { + element.children().forEach { child -> + if (child.tagName() == "li") { + Row( + modifier = Modifier.padding(vertical = 2.dp), + ) { + Text( + modifier = Modifier.padding(top = 4.dp), + text = AnnotatedString("• "), + style = TextStyle(fontWeight = FontWeight.Bold), + color = MaterialTheme.appColors.textPrimary + ) + RenderBlockElement(child, indent + 1) + } + } + } + } + } + + "ol" -> { + Column(modifier = Modifier.padding(start = (indent + 1) * 16.dp)) { + element.children().forEachIndexed { index, child -> + if (child.tagName() == "li") { + Row( + modifier = Modifier.padding(vertical = 2.dp), + ) { + Text( + modifier = Modifier.padding(top = 4.dp), + text = AnnotatedString("${index + 1}. "), + color = MaterialTheme.appColors.textPrimary + ) + RenderBlockElement(child, indent + 1) + } + } + } + } + } + + "li" -> { + RenderParagraph(element) + } + + "blockquote" -> { + Row( + modifier = Modifier.height(IntrinsicSize.Min), + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + Box( + modifier = Modifier + .width(2.dp) + .fillMaxHeight() + .background(MaterialTheme.appColors.cardViewBorder) + ) + Column { + element.children().forEach { child -> + RenderBlockElement(child) + } + } + } + } + + "img" -> { + val src = element.attr("src") + AsyncImage( + modifier = Modifier.fillMaxWidth(), + model = ImageRequest.Builder(LocalContext.current) + .data(src) + .error(R.drawable.core_no_image_course) + .placeholder(R.drawable.core_no_image_course) + .build(), + contentDescription = null, + contentScale = ContentScale.FillWidth + ) + } + + else -> { + RenderParagraph(element) + } + } +} + +@Composable +private fun AnnotatedString.Builder.AppendNodes(nodes: List) { + nodes.forEach { node -> + when (node) { + is TextNode -> append(node.text()) + is Element -> AppendElement(node) + } + } +} + +@Composable +private fun AnnotatedString.Builder.AppendElement(element: Element) { + when (element.tagName()) { + "br" -> append("\n") + "strong" -> withStyle(SpanStyle(fontWeight = FontWeight.Bold)) { + AppendNodes(element.childNodes()) + } + + "em" -> withStyle(SpanStyle(fontStyle = FontStyle.Italic)) { + AppendNodes(element.childNodes()) + } + + "code" -> withStyle(SpanStyle(fontFamily = FontFamily.Monospace)) { + AppendNodes(element.childNodes()) + } + + "span" -> { + val styleAttr = element.attr("style") + if (styleAttr.contains("text-decoration: underline", ignoreCase = true)) { + withStyle(SpanStyle(textDecoration = TextDecoration.Underline)) { + AppendNodes(element.childNodes()) + } + } else { + AppendNodes(element.childNodes()) + } + } + + "a" -> { + val href = element.attr("href") + val start = this.length + AppendNodes(element.childNodes()) + val end = this.length + addStyle( + SpanStyle( + color = MaterialTheme.appColors.primary, + textDecoration = TextDecoration.Underline + ), + start, + end + ) + addStringAnnotation(tag = "URL", annotation = href, start = start, end = end) + } + + else -> AppendNodes(element.childNodes()) + } +} + +@Composable +private fun buildAnnotatedStringFromNodes(nodes: List): AnnotatedString { + return AnnotatedString.Builder().apply { + AppendNodes(nodes) + }.toAnnotatedString() +} + +private fun flush(currentSegment: MutableList, segments: MutableList) { + if (currentSegment.isNotEmpty()) { + segments.add(currentSegment.toList()) + currentSegment.clear() + } +} diff --git a/discussion/src/main/java/org/openedx/discussion/data/model/response/CommentsResponse.kt b/discussion/src/main/java/org/openedx/discussion/data/model/response/CommentsResponse.kt index 77b50e504..08bf03a0f 100644 --- a/discussion/src/main/java/org/openedx/discussion/data/model/response/CommentsResponse.kt +++ b/discussion/src/main/java/org/openedx/discussion/data/model/response/CommentsResponse.kt @@ -3,7 +3,6 @@ package org.openedx.discussion.data.model.response import com.google.gson.annotations.SerializedName import org.openedx.core.data.model.Pagination import org.openedx.core.data.model.ProfileImage -import org.openedx.core.extension.TextConverter import org.openedx.discussion.domain.model.CommentsData import org.openedx.discussion.domain.model.DiscussionComment @@ -78,7 +77,6 @@ data class CommentResult( updatedAt, rawBody, renderedBody, - TextConverter.textToLinkedImageText(renderedBody), abuseFlagged, voted, voteCount, diff --git a/discussion/src/main/java/org/openedx/discussion/data/model/response/ThreadsResponse.kt b/discussion/src/main/java/org/openedx/discussion/data/model/response/ThreadsResponse.kt index b34005c04..c8f56ff8e 100644 --- a/discussion/src/main/java/org/openedx/discussion/data/model/response/ThreadsResponse.kt +++ b/discussion/src/main/java/org/openedx/discussion/data/model/response/ThreadsResponse.kt @@ -3,7 +3,6 @@ package org.openedx.discussion.data.model.response import com.google.gson.annotations.SerializedName import org.openedx.core.data.model.Pagination import org.openedx.core.data.model.ProfileImage -import org.openedx.core.extension.TextConverter import org.openedx.discussion.domain.model.DiscussionType import org.openedx.discussion.domain.model.ThreadsData @@ -104,7 +103,6 @@ data class ThreadsResponse( updatedAt, rawBody, renderedBody, - TextConverter.textToLinkedImageText(renderedBody), abuseFlagged, voted, voteCount, diff --git a/discussion/src/main/java/org/openedx/discussion/domain/model/DiscussionComment.kt b/discussion/src/main/java/org/openedx/discussion/domain/model/DiscussionComment.kt index 13a2fba9c..6ffcc3d64 100644 --- a/discussion/src/main/java/org/openedx/discussion/domain/model/DiscussionComment.kt +++ b/discussion/src/main/java/org/openedx/discussion/domain/model/DiscussionComment.kt @@ -3,7 +3,6 @@ package org.openedx.discussion.domain.model import android.os.Parcelable import kotlinx.parcelize.Parcelize import org.openedx.core.domain.model.ProfileImage -import org.openedx.core.extension.LinkedImageText @Parcelize data class DiscussionComment( @@ -14,7 +13,6 @@ data class DiscussionComment( val updatedAt: String, val rawBody: String, val renderedBody: String, - val parsedRenderedBody: LinkedImageText, val abuseFlagged: Boolean, val voted: Boolean, val voteCount: Int, diff --git a/discussion/src/main/java/org/openedx/discussion/domain/model/Thread.kt b/discussion/src/main/java/org/openedx/discussion/domain/model/Thread.kt index 9b7f2498c..c87cbc368 100644 --- a/discussion/src/main/java/org/openedx/discussion/domain/model/Thread.kt +++ b/discussion/src/main/java/org/openedx/discussion/domain/model/Thread.kt @@ -3,7 +3,6 @@ package org.openedx.discussion.domain.model import android.os.Parcelable import kotlinx.parcelize.Parcelize import org.openedx.core.domain.model.ProfileImage -import org.openedx.core.extension.LinkedImageText import org.openedx.discussion.R @Parcelize @@ -15,7 +14,6 @@ data class Thread( val updatedAt: String, val rawBody: String, val renderedBody: String, - val parsedRenderedBody: LinkedImageText, val abuseFlagged: Boolean, val voted: Boolean, val voteCount: Int, diff --git a/discussion/src/main/java/org/openedx/discussion/presentation/comments/DiscussionCommentsFragment.kt b/discussion/src/main/java/org/openedx/discussion/presentation/comments/DiscussionCommentsFragment.kt index b33646b9a..5bbee6ff9 100644 --- a/discussion/src/main/java/org/openedx/discussion/presentation/comments/DiscussionCommentsFragment.kt +++ b/discussion/src/main/java/org/openedx/discussion/presentation/comments/DiscussionCommentsFragment.kt @@ -72,7 +72,6 @@ import org.koin.android.ext.android.inject import org.koin.androidx.viewmodel.ext.android.viewModel import org.koin.core.parameter.parametersOf import org.openedx.core.domain.model.ProfileImage -import org.openedx.core.extension.TextConverter import org.openedx.core.ui.BackBtn import org.openedx.core.ui.HandleUIMessage import org.openedx.core.ui.displayCutoutForLandscape @@ -550,7 +549,6 @@ private val mockThread = org.openedx.discussion.domain.model.Thread( "", "", "", - TextConverter.textToLinkedImageText(""), false, true, 20, @@ -585,7 +583,6 @@ private val mockComment = DiscussionComment( "", "", "", - TextConverter.textToLinkedImageText(""), false, true, 20, diff --git a/discussion/src/main/java/org/openedx/discussion/presentation/responses/DiscussionResponsesFragment.kt b/discussion/src/main/java/org/openedx/discussion/presentation/responses/DiscussionResponsesFragment.kt index 736455a7e..863cc89ef 100644 --- a/discussion/src/main/java/org/openedx/discussion/presentation/responses/DiscussionResponsesFragment.kt +++ b/discussion/src/main/java/org/openedx/discussion/presentation/responses/DiscussionResponsesFragment.kt @@ -12,11 +12,9 @@ import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.IntrinsicSize import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height @@ -47,6 +45,7 @@ import androidx.compose.material.rememberScaffoldState import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.livedata.observeAsState +import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.saveable.rememberSaveable @@ -56,7 +55,9 @@ import androidx.compose.ui.ExperimentalComposeUiApi import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color +import androidx.compose.ui.layout.onGloballyPositioned import androidx.compose.ui.platform.ComposeView +import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalFocusManager import androidx.compose.ui.platform.LocalSoftwareKeyboardController import androidx.compose.ui.platform.ViewCompositionStrategy @@ -75,7 +76,6 @@ import org.koin.android.ext.android.inject import org.koin.androidx.viewmodel.ext.android.viewModel import org.koin.core.parameter.parametersOf import org.openedx.core.domain.model.ProfileImage -import org.openedx.core.extension.TextConverter import org.openedx.core.ui.BackBtn import org.openedx.core.ui.HandleUIMessage import org.openedx.core.ui.displayCutoutForLandscape @@ -85,6 +85,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.discussion.R import org.openedx.discussion.domain.model.DiscussionComment import org.openedx.discussion.presentation.DiscussionRouter import org.openedx.discussion.presentation.comments.DiscussionCommentsFragment @@ -217,8 +218,9 @@ private fun DiscussionResponsesScreen( val focusManager = LocalFocusManager.current val firstVisibleIndex = remember { - mutableStateOf(scrollState.firstVisibleItemIndex) + mutableIntStateOf(scrollState.firstVisibleItemIndex) } + val isShouldLoadMore = scrollState.shouldLoadMore(firstVisibleIndex, LOAD_MORE_THRESHOLD) val pullRefreshState = rememberPullRefreshState(refreshing = refreshing, onRefresh = { onSwipeRefresh() }) @@ -362,7 +364,7 @@ private fun DiscussionResponsesScreen( .padding(horizontal = paddingContent) .padding(top = 24.dp, bottom = 8.dp), text = pluralStringResource( - id = org.openedx.discussion.R.plurals.discussion_comments, + id = R.plurals.discussion_comments, uiState.mainComment.childCount, uiState.mainComment.childCount ), @@ -374,23 +376,31 @@ private fun DiscussionResponsesScreen( } items(uiState.childComments) { comment -> + var itemHeight by remember { mutableIntStateOf(0) } + val boxHeight = if (itemHeight > 0) { + Modifier.height(with(LocalDensity.current) { itemHeight.toDp() }) + } else { + Modifier + } Row( Modifier .fillMaxWidth() - .height(IntrinsicSize.Min) .padding(start = paddingContent), verticalAlignment = Alignment.CenterVertically ) { Box( modifier = Modifier - .fillMaxHeight() .width(1.dp) + .then(boxHeight) .background(MaterialTheme.appColors.cardViewBorder) ) CommentMainItem( modifier = Modifier .padding(4.dp) - .fillMaxWidth(), + .fillMaxWidth() + .onGloballyPositioned { coordinates -> + itemHeight = coordinates.size.height + }, comment = comment, onClick = { action, commentId, bool -> onItemClick(action, commentId, bool) @@ -412,7 +422,7 @@ private fun DiscussionResponsesScreen( } } } - if (scrollState.shouldLoadMore(firstVisibleIndex, LOAD_MORE_THRESHOLD)) { + if (isShouldLoadMore) { paginationCallBack() } } @@ -449,7 +459,7 @@ private fun DiscussionResponsesScreen( placeholder = { Text( text = stringResource( - id = org.openedx.discussion.R.string.discussion_add_comment + id = R.string.discussion_add_comment ), color = MaterialTheme.appColors.textFieldHint, style = MaterialTheme.appTypography.labelLarge, @@ -480,7 +490,7 @@ private fun DiscussionResponsesScreen( Icon( modifier = Modifier.padding(7.dp), painter = painterResource( - id = org.openedx.discussion.R.drawable.discussion_ic_send + id = R.drawable.discussion_ic_send ), contentDescription = null, tint = iconButtonColor @@ -578,7 +588,6 @@ private val mockComment = DiscussionComment( "", "", "", - TextConverter.textToLinkedImageText(""), false, true, 20, diff --git a/discussion/src/main/java/org/openedx/discussion/presentation/search/DiscussionSearchThreadFragment.kt b/discussion/src/main/java/org/openedx/discussion/presentation/search/DiscussionSearchThreadFragment.kt index a8a835603..e67fe40b3 100644 --- a/discussion/src/main/java/org/openedx/discussion/presentation/search/DiscussionSearchThreadFragment.kt +++ b/discussion/src/main/java/org/openedx/discussion/presentation/search/DiscussionSearchThreadFragment.kt @@ -60,7 +60,6 @@ import org.koin.android.ext.android.inject import org.koin.androidx.viewmodel.ext.android.viewModel import org.koin.core.parameter.parametersOf import org.openedx.core.R -import org.openedx.core.extension.TextConverter import org.openedx.core.ui.BackBtn import org.openedx.core.ui.HandleUIMessage import org.openedx.core.ui.SearchBar @@ -414,7 +413,6 @@ private val mockThread = org.openedx.discussion.domain.model.Thread( "", "", "", - TextConverter.textToLinkedImageText(""), false, true, 20, 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 b68379afe..f610dfa9d 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 @@ -74,7 +74,6 @@ import org.koin.android.ext.android.inject import org.koin.androidx.viewmodel.ext.android.viewModel import org.koin.core.parameter.parametersOf import org.openedx.core.FragmentViewType -import org.openedx.core.extension.TextConverter import org.openedx.core.ui.BackBtn import org.openedx.core.ui.HandleUIMessage import org.openedx.core.ui.IconText @@ -742,7 +741,6 @@ private val mockThread = org.openedx.discussion.domain.model.Thread( "", "", "", - TextConverter.textToLinkedImageText(""), false, true, 20, 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 64dd4dcd0..1a544e40a 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 @@ -1,5 +1,3 @@ -@file:OptIn(ExperimentalComposeUiApi::class) - package org.openedx.discussion.presentation.ui import android.content.res.Configuration.UI_MODE_NIGHT_NO @@ -31,7 +29,6 @@ import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight import androidx.compose.material.icons.automirrored.outlined.HelpOutline import androidx.compose.runtime.Composable 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.graphics.Shape @@ -48,10 +45,9 @@ import androidx.compose.ui.unit.dp import coil.compose.AsyncImage import coil.request.ImageRequest import org.openedx.core.domain.model.ProfileImage -import org.openedx.core.extension.TextConverter import org.openedx.core.ui.AutoSizeText -import org.openedx.core.ui.HyperlinkImageText import org.openedx.core.ui.IconText +import org.openedx.core.ui.RenderHtmlContent import org.openedx.core.ui.theme.OpenEdXTheme import org.openedx.core.ui.theme.appColors import org.openedx.core.ui.theme.appShapes @@ -162,10 +158,8 @@ fun ThreadMainItem( ) } Spacer(modifier = Modifier.height(24.dp)) - HyperlinkImageText( - title = thread.title, - imageText = thread.parsedRenderedBody, - linkTextColor = MaterialTheme.appColors.primary + RenderHtmlContent( + html = thread.rawBody, ) Spacer(modifier = Modifier.height(24.dp)) Row( @@ -316,9 +310,8 @@ fun CommentItem( ) } Spacer(modifier = Modifier.height(14.dp)) - HyperlinkImageText( - imageText = comment.parsedRenderedBody, - linkTextColor = MaterialTheme.appColors.primary + RenderHtmlContent( + html = comment.rawBody, ) Spacer(modifier = Modifier.height(16.dp)) Row( @@ -455,9 +448,8 @@ fun CommentMainItem( } } Spacer(modifier = Modifier.height(14.dp)) - HyperlinkImageText( - imageText = comment.parsedRenderedBody, - linkTextColor = MaterialTheme.appColors.primary + RenderHtmlContent( + html = comment.rawBody, ) Spacer(modifier = Modifier.height(16.dp)) Row( @@ -723,7 +715,6 @@ private val mockComment = DiscussionComment( "", "", "", - TextConverter.textToLinkedImageText(""), false, true, 20, @@ -749,7 +740,6 @@ private val mockThread = org.openedx.discussion.domain.model.Thread( "", "", "", - TextConverter.textToLinkedImageText(""), false, true, 20, diff --git a/discussion/src/test/java/org/openedx/discussion/presentation/comments/DiscussionCommentsViewModelTest.kt b/discussion/src/test/java/org/openedx/discussion/presentation/comments/DiscussionCommentsViewModelTest.kt index e9323270e..f3a9704f5 100644 --- a/discussion/src/test/java/org/openedx/discussion/presentation/comments/DiscussionCommentsViewModelTest.kt +++ b/discussion/src/test/java/org/openedx/discussion/presentation/comments/DiscussionCommentsViewModelTest.kt @@ -27,7 +27,6 @@ import org.junit.rules.TestRule import org.openedx.core.R import org.openedx.core.data.storage.CorePreferences import org.openedx.core.domain.model.Pagination -import org.openedx.core.extension.TextConverter import org.openedx.discussion.domain.interactor.DiscussionInteractor import org.openedx.discussion.domain.model.CommentsData import org.openedx.discussion.domain.model.DiscussionComment @@ -68,7 +67,6 @@ class DiscussionCommentsViewModelTest { "", "", "", - TextConverter.textToLinkedImageText(""), false, true, 20, @@ -107,7 +105,6 @@ class DiscussionCommentsViewModelTest { "", "", "", - TextConverter.textToLinkedImageText(""), false, true, 20, diff --git a/discussion/src/test/java/org/openedx/discussion/presentation/responses/DiscussionResponsesViewModelTest.kt b/discussion/src/test/java/org/openedx/discussion/presentation/responses/DiscussionResponsesViewModelTest.kt index ac57556bc..bb3579eda 100644 --- a/discussion/src/test/java/org/openedx/discussion/presentation/responses/DiscussionResponsesViewModelTest.kt +++ b/discussion/src/test/java/org/openedx/discussion/presentation/responses/DiscussionResponsesViewModelTest.kt @@ -22,11 +22,9 @@ import org.junit.rules.TestRule import org.openedx.core.R import org.openedx.core.data.storage.CorePreferences import org.openedx.core.domain.model.Pagination -import org.openedx.core.extension.LinkedImageText import org.openedx.discussion.domain.interactor.DiscussionInteractor import org.openedx.discussion.domain.model.CommentsData import org.openedx.discussion.domain.model.DiscussionComment -import org.openedx.discussion.domain.model.DiscussionType import org.openedx.discussion.system.notifier.DiscussionNotifier import org.openedx.foundation.presentation.UIMessage import org.openedx.foundation.system.ResourceManager @@ -49,45 +47,6 @@ class DiscussionResponsesViewModelTest { private val somethingWrong = "Something went wrong" private val commentAddedSuccessfully = "Comment Successfully added" - //region mockThread - - val mockThread = org.openedx.discussion.domain.model.Thread( - "", - "", - "", - "", - "", - "", - "", - LinkedImageText("", emptyMap(), emptyMap(), emptyList()), - false, - true, - 20, - emptyList(), - false, - "", - "", - "", - "", - DiscussionType.DISCUSSION, - "", - "", - "Discussion title long Discussion title long good item", - true, - false, - true, - 21, - 4, - false, - false, - mapOf(), - 0, - false, - false - ) - - //endregion - //region mockComment private val mockComment = DiscussionComment( @@ -98,7 +57,6 @@ class DiscussionResponsesViewModelTest { "", "", "", - LinkedImageText("", emptyMap(), emptyMap(), emptyList()), false, true, 20, diff --git a/discussion/src/test/java/org/openedx/discussion/presentation/search/DiscussionSearchThreadViewModelTest.kt b/discussion/src/test/java/org/openedx/discussion/presentation/search/DiscussionSearchThreadViewModelTest.kt index 39e01c194..14eb3f062 100644 --- a/discussion/src/test/java/org/openedx/discussion/presentation/search/DiscussionSearchThreadViewModelTest.kt +++ b/discussion/src/test/java/org/openedx/discussion/presentation/search/DiscussionSearchThreadViewModelTest.kt @@ -24,7 +24,6 @@ import org.junit.Test import org.junit.rules.TestRule import org.openedx.core.R import org.openedx.core.domain.model.Pagination -import org.openedx.core.extension.TextConverter import org.openedx.discussion.domain.interactor.DiscussionInteractor import org.openedx.discussion.domain.model.DiscussionType import org.openedx.discussion.domain.model.ThreadsData @@ -59,7 +58,6 @@ class DiscussionSearchThreadViewModelTest { "", "", "", - TextConverter.textToLinkedImageText(""), false, true, 20, diff --git a/discussion/src/test/java/org/openedx/discussion/presentation/threads/DiscussionAddThreadViewModelTest.kt b/discussion/src/test/java/org/openedx/discussion/presentation/threads/DiscussionAddThreadViewModelTest.kt index 9dc8ba339..65b4a1ae8 100644 --- a/discussion/src/test/java/org/openedx/discussion/presentation/threads/DiscussionAddThreadViewModelTest.kt +++ b/discussion/src/test/java/org/openedx/discussion/presentation/threads/DiscussionAddThreadViewModelTest.kt @@ -19,7 +19,6 @@ import org.junit.Rule import org.junit.Test import org.junit.rules.TestRule import org.openedx.core.R -import org.openedx.core.extension.TextConverter import org.openedx.discussion.domain.interactor.DiscussionInteractor import org.openedx.discussion.domain.model.DiscussionType import org.openedx.discussion.domain.model.Topic @@ -53,7 +52,6 @@ class DiscussionAddThreadViewModelTest { "", "", "", - TextConverter.textToLinkedImageText(""), false, true, 20, diff --git a/discussion/src/test/java/org/openedx/discussion/presentation/threads/DiscussionThreadsViewModelTest.kt b/discussion/src/test/java/org/openedx/discussion/presentation/threads/DiscussionThreadsViewModelTest.kt index ae4f966ba..15e49570d 100644 --- a/discussion/src/test/java/org/openedx/discussion/presentation/threads/DiscussionThreadsViewModelTest.kt +++ b/discussion/src/test/java/org/openedx/discussion/presentation/threads/DiscussionThreadsViewModelTest.kt @@ -26,7 +26,6 @@ import org.junit.Test import org.junit.rules.TestRule import org.openedx.core.R import org.openedx.core.domain.model.Pagination -import org.openedx.core.extension.TextConverter import org.openedx.discussion.domain.interactor.DiscussionInteractor import org.openedx.discussion.domain.model.DiscussionType import org.openedx.discussion.domain.model.ThreadsData @@ -63,7 +62,6 @@ class DiscussionThreadsViewModelTest { "", "", "", - TextConverter.textToLinkedImageText(""), false, true, 20, From ddcb57d267c13e7d445dcf667ba1005797eca427 Mon Sep 17 00:00:00 2001 From: Kirill Izmaylov Date: Mon, 7 Apr 2025 11:06:42 +0300 Subject: [PATCH 06/24] fix: issue to load the video through chrome-cast device (#83) (#436) fixes: LEARNER-10350 Co-authored-by: Farhan Arshad <43750646+farhan-arshad-dev@users.noreply.github.com> --- .../presentation/unit/video/EncodedVideoUnitViewModel.kt | 1 + .../course/presentation/unit/video/VideoUnitViewModel.kt | 8 ++++---- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/course/src/main/java/org/openedx/course/presentation/unit/video/EncodedVideoUnitViewModel.kt b/course/src/main/java/org/openedx/course/presentation/unit/video/EncodedVideoUnitViewModel.kt index 17adfcddf..08fde815b 100644 --- a/course/src/main/java/org/openedx/course/presentation/unit/video/EncodedVideoUnitViewModel.kt +++ b/course/src/main/java/org/openedx/course/presentation/unit/video/EncodedVideoUnitViewModel.kt @@ -106,6 +106,7 @@ class EncodedVideoUnitViewModel( CastContext.getSharedInstance(context, executor).addOnCompleteListener { it.result?.let { castContext -> castPlayer = CastPlayer(castContext) + isUpdatedMutable.value = true } } } diff --git a/course/src/main/java/org/openedx/course/presentation/unit/video/VideoUnitViewModel.kt b/course/src/main/java/org/openedx/course/presentation/unit/video/VideoUnitViewModel.kt index 63425ffec..0360d9dc6 100644 --- a/course/src/main/java/org/openedx/course/presentation/unit/video/VideoUnitViewModel.kt +++ b/course/src/main/java/org/openedx/course/presentation/unit/video/VideoUnitViewModel.kt @@ -40,9 +40,9 @@ open class VideoUnitViewModel( val currentVideoTime: LiveData get() = _currentVideoTime - private val _isUpdated = MutableLiveData(true) + protected val isUpdatedMutable = MutableLiveData(true) val isUpdated: LiveData - get() = _isUpdated + get() = isUpdatedMutable private val _currentIndex = MutableStateFlow(0) val currentIndex = _currentIndex.asStateFlow() @@ -63,9 +63,9 @@ open class VideoUnitViewModel( viewModelScope.launch { notifier.notifier.collect { if (it is CourseVideoPositionChanged && videoUrl == it.videoUrl) { - _isUpdated.value = false + isUpdatedMutable.value = false _currentVideoTime.value = it.videoTime - _isUpdated.value = true + isUpdatedMutable.value = true isPlaying = it.isPlaying } else if (it is CourseSubtitleLanguageChanged) { transcriptLanguage = it.value From 9014e6386a60b0fafb14351387385e8d63c7c907 Mon Sep 17 00:00:00 2001 From: PavloNetrebchuk <141041606+PavloNetrebchuk@users.noreply.github.com> Date: Wed, 9 Apr 2025 10:59:01 +0300 Subject: [PATCH 07/24] fix: fixed upgrade item on SettingsFragment, moved upgrade popup to MainFragment (#438) --- .../main/java/org/openedx/app/MainFragment.kt | 61 +++++++++++++++++++ .../java/org/openedx/app/MainViewModel.kt | 42 ++++++++++--- .../data/networking/AppUpgradeInterceptor.kt | 18 ++++-- .../app/data/networking/HeadersInterceptor.kt | 13 +--- .../java/org/openedx/app/di/ScreenModule.kt | 6 +- app/src/main/res/layout/fragment_main.xml | 7 +++ .../java/org/openedx/core/AppUpdateState.kt | 16 +++-- .../appupgrade/AppUpgradeDialogFragment.kt | 4 +- .../presentation/DashboardListFragment.kt | 31 ++-------- .../presentation/DashboardListViewModel.kt | 18 ------ .../DashboardListViewModelTest.kt | 27 -------- .../presentation/NativeDiscoveryFragment.kt | 59 ++---------------- .../presentation/NativeDiscoveryViewModel.kt | 28 --------- .../NativeDiscoveryViewModelTest.kt | 15 ----- .../presentation/settings/SettingsFragment.kt | 2 - .../presentation/settings/SettingsScreenUI.kt | 7 +-- .../settings/SettingsViewModel.kt | 16 ----- 17 files changed, 146 insertions(+), 224 deletions(-) diff --git a/app/src/main/java/org/openedx/app/MainFragment.kt b/app/src/main/java/org/openedx/app/MainFragment.kt index c2b5041c7..c58ec437f 100644 --- a/app/src/main/java/org/openedx/app/MainFragment.kt +++ b/app/src/main/java/org/openedx/app/MainFragment.kt @@ -3,6 +3,11 @@ package org.openedx.app import android.os.Bundle import android.view.Menu import android.view.View +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.runtime.getValue +import androidx.compose.runtime.livedata.observeAsState +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier import androidx.core.os.bundleOf import androidx.core.view.forEach import androidx.fragment.app.Fragment @@ -14,9 +19,14 @@ import org.koin.android.ext.android.inject import org.koin.androidx.viewmodel.ext.android.viewModel import org.openedx.app.databinding.FragmentMainBinding import org.openedx.app.deeplink.HomeTab +import org.openedx.core.AppUpdateState +import org.openedx.core.AppUpdateState.wasUpgradeDialogClosed import org.openedx.core.adapter.NavigationFragmentAdapter +import org.openedx.core.presentation.dialog.appupgrade.AppUpgradeDialogFragment +import org.openedx.core.presentation.global.appupgrade.AppUpgradeRecommendedBox import org.openedx.core.presentation.global.appupgrade.UpgradeRequiredFragment import org.openedx.core.presentation.global.viewBinding +import org.openedx.core.system.notifier.app.AppUpgradeEvent import org.openedx.discovery.presentation.DiscoveryRouter import org.openedx.downloads.presentation.download.DownloadsFragment import org.openedx.learn.presentation.LearnFragment @@ -45,6 +55,7 @@ class MainFragment : Fragment(R.layout.fragment_main) { handleArguments() setupBottomNavigation() setupViewPager() + setupBottomPopup() observeViewModel() } @@ -186,6 +197,56 @@ class MainFragment : Fragment(R.layout.fragment_main) { } } + private fun setupBottomPopup() { + binding.composeBottomPopup.setContent { + val appUpgradeEvent by viewModel.appUpgradeEvent.observeAsState() + val wasUpgradeDialogClosed by remember { wasUpgradeDialogClosed } + val appUpgradeParameters = AppUpdateState.AppUpgradeParameters( + appUpgradeEvent = appUpgradeEvent, + wasUpgradeDialogClosed = wasUpgradeDialogClosed, + appUpgradeRecommendedDialog = { + val dialog = AppUpgradeDialogFragment.newInstance() + dialog.show( + requireActivity().supportFragmentManager, + AppUpgradeDialogFragment::class.simpleName + ) + }, + onAppUpgradeRecommendedBoxClick = { + AppUpdateState.openPlayMarket(requireContext()) + }, + onAppUpgradeRequired = { + router.navigateToUpgradeRequired( + requireActivity().supportFragmentManager + ) + } + ) + when (appUpgradeParameters.appUpgradeEvent) { + is AppUpgradeEvent.UpgradeRecommendedEvent -> { + if (appUpgradeParameters.wasUpgradeDialogClosed) { + AppUpgradeRecommendedBox( + modifier = Modifier.fillMaxWidth(), + onClick = appUpgradeParameters.onAppUpgradeRecommendedBoxClick + ) + } else { + if (!AppUpdateState.wasUpdateDialogDisplayed) { + AppUpdateState.wasUpdateDialogDisplayed = true + appUpgradeParameters.appUpgradeRecommendedDialog() + } + } + } + + is AppUpgradeEvent.UpgradeRequiredEvent -> { + if (!AppUpdateState.wasUpdateDialogDisplayed) { + AppUpdateState.wasUpdateDialogDisplayed = true + appUpgradeParameters.onAppUpgradeRequired() + } + } + + else -> {} + } + } + } + companion object { private const val ARG_COURSE_ID = "courseId" private const val ARG_INFO_TYPE = "info_type" diff --git a/app/src/main/java/org/openedx/app/MainViewModel.kt b/app/src/main/java/org/openedx/app/MainViewModel.kt index 2d2033769..8723d6dbe 100644 --- a/app/src/main/java/org/openedx/app/MainViewModel.kt +++ b/app/src/main/java/org/openedx/app/MainViewModel.kt @@ -10,9 +10,12 @@ import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.launch import org.openedx.core.config.Config import org.openedx.core.system.notifier.DiscoveryNotifier import org.openedx.core.system.notifier.NavigationToDiscovery +import org.openedx.core.system.notifier.app.AppNotifier +import org.openedx.core.system.notifier.app.AppUpgradeEvent import org.openedx.discovery.presentation.DiscoveryNavigator import org.openedx.foundation.presentation.BaseViewModel @@ -20,6 +23,7 @@ class MainViewModel( private val config: Config, private val notifier: DiscoveryNotifier, private val analytics: AppAnalytics, + private val appNotifier: AppNotifier, ) : BaseViewModel() { private val _isBottomBarEnabled = MutableLiveData(true) @@ -30,6 +34,10 @@ class MainViewModel( val navigateToDiscovery: SharedFlow get() = _navigateToDiscovery.asSharedFlow() + private val _appUpgradeEvent = MutableLiveData() + val appUpgradeEvent: LiveData + get() = _appUpgradeEvent + val isDiscoveryTypeWebView get() = config.getDiscoveryConfig().isViewTypeWebView() val getDiscoveryFragment get() = DiscoveryNavigator(isDiscoveryTypeWebView).getDiscoveryFragment() @@ -37,14 +45,8 @@ class MainViewModel( override fun onCreate(owner: LifecycleOwner) { super.onCreate(owner) - notifier.notifier - .onEach { - if (it is NavigationToDiscovery) { - _navigateToDiscovery.emit(true) - } - } - .distinctUntilChanged() - .launchIn(viewModelScope) + collectDiscoveryEvents() + collectAppUpgradeEvent() } fun enableBottomBar(enable: Boolean) { @@ -75,4 +77,28 @@ class MainViewModel( } ) } + + private fun collectDiscoveryEvents() { + notifier.notifier + .onEach { + if (it is NavigationToDiscovery) { + _navigateToDiscovery.emit(true) + } + } + .distinctUntilChanged() + .launchIn(viewModelScope) + } + + private fun collectAppUpgradeEvent() { + viewModelScope.launch { + appNotifier.notifier + .onEach { event -> + if (event is AppUpgradeEvent) { + _appUpgradeEvent.value = event + } + } + .distinctUntilChanged() + .launchIn(viewModelScope) + } + } } diff --git a/app/src/main/java/org/openedx/app/data/networking/AppUpgradeInterceptor.kt b/app/src/main/java/org/openedx/app/data/networking/AppUpgradeInterceptor.kt index e789ed52b..e3add144d 100644 --- a/app/src/main/java/org/openedx/app/data/networking/AppUpgradeInterceptor.kt +++ b/app/src/main/java/org/openedx/app/data/networking/AppUpgradeInterceptor.kt @@ -4,6 +4,7 @@ import kotlinx.coroutines.runBlocking import okhttp3.Interceptor import okhttp3.Response import org.openedx.app.BuildConfig +import org.openedx.core.AppUpdateState import org.openedx.core.system.notifier.app.AppNotifier import org.openedx.core.system.notifier.app.AppUpgradeEvent import org.openedx.core.utils.TimeUtils @@ -17,23 +18,30 @@ class AppUpgradeInterceptor( val responseCode = response.code val latestAppVersion = response.header(HEADER_APP_LATEST_VERSION) ?: "" val lastSupportedDateString = response.header(HEADER_APP_VERSION_LAST_SUPPORTED_DATE) ?: "" - val lastSupportedDateTime = TimeUtils.iso8601WithTimeZoneToDate(lastSupportedDateString)?.time ?: 0L + val lastSupportedDateTime = + TimeUtils.iso8601WithTimeZoneToDate(lastSupportedDateString)?.time ?: 0L runBlocking { - when { + val appUpgradeEvent = when { responseCode == 426 -> { - appNotifier.send(AppUpgradeEvent.UpgradeRequiredEvent) + AppUpgradeEvent.UpgradeRequiredEvent } BuildConfig.VERSION_NAME != latestAppVersion && lastSupportedDateTime > Date().time -> { - appNotifier.send(AppUpgradeEvent.UpgradeRecommendedEvent(latestAppVersion)) + AppUpgradeEvent.UpgradeRecommendedEvent(latestAppVersion) } latestAppVersion.isNotEmpty() && BuildConfig.VERSION_NAME != latestAppVersion && lastSupportedDateTime < Date().time -> { - appNotifier.send(AppUpgradeEvent.UpgradeRequiredEvent) + AppUpgradeEvent.UpgradeRequiredEvent + } + + else -> { + return@runBlocking } } + AppUpdateState.lastAppUpgradeEvent = appUpgradeEvent + appNotifier.send(appUpgradeEvent) } return response } diff --git a/app/src/main/java/org/openedx/app/data/networking/HeadersInterceptor.kt b/app/src/main/java/org/openedx/app/data/networking/HeadersInterceptor.kt index bdc7c6284..a4daf0809 100644 --- a/app/src/main/java/org/openedx/app/data/networking/HeadersInterceptor.kt +++ b/app/src/main/java/org/openedx/app/data/networking/HeadersInterceptor.kt @@ -1,14 +1,13 @@ package org.openedx.app.data.networking -import android.content.Context import okhttp3.Interceptor import okhttp3.Response -import org.openedx.app.BuildConfig import org.openedx.core.config.Config import org.openedx.core.data.storage.CorePreferences +import org.openedx.core.presentation.global.AppData class HeadersInterceptor( - private val context: Context, + private val appData: AppData, private val config: Config, private val preferencesManager: CorePreferences, ) : Interceptor { @@ -26,13 +25,7 @@ class HeadersInterceptor( addHeader("Accept", "application/json") val httpAgent = System.getProperty("http.agent") ?: "" - addHeader( - "User-Agent", - httpAgent + " " + - context.getString(org.openedx.core.R.string.app_name) + "/" + - BuildConfig.APPLICATION_ID + "/" + - BuildConfig.VERSION_NAME - ) + addHeader("User-Agent", "$httpAgent ${appData.versionName}") }.build() ) } 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 d00d0f1fe..464007259 100644 --- a/app/src/main/java/org/openedx/app/di/ScreenModule.kt +++ b/app/src/main/java/org/openedx/app/di/ScreenModule.kt @@ -91,7 +91,7 @@ val screenModule = module { get(), ) } - viewModel { MainViewModel(get(), get(), get()) } + viewModel { MainViewModel(get(), get(), get(), get()) } factory { AuthRepository(get(), get(), get()) } factory { AuthInteractor(get()) } @@ -148,7 +148,7 @@ val screenModule = module { factory { DashboardRepository(get(), get(), get(), get()) } factory { DashboardInteractor(get()) } - viewModel { DashboardListViewModel(get(), get(), get(), get(), get(), get(), get()) } + viewModel { DashboardListViewModel(get(), get(), get(), get(), get(), get()) } viewModel { (windowSize: WindowSize) -> DashboardGalleryViewModel( get(), @@ -169,7 +169,7 @@ val screenModule = module { factory { DiscoveryRepository(get(), get(), get()) } factory { DiscoveryInteractor(get()) } - viewModel { NativeDiscoveryViewModel(get(), get(), get(), get(), get(), get(), get()) } + viewModel { NativeDiscoveryViewModel(get(), get(), get(), get(), get(), get()) } viewModel { (querySearch: String) -> WebViewDiscoveryViewModel( querySearch, diff --git a/app/src/main/res/layout/fragment_main.xml b/app/src/main/res/layout/fragment_main.xml index 9a4861379..362793686 100644 --- a/app/src/main/res/layout/fragment_main.xml +++ b/app/src/main/res/layout/fragment_main.xml @@ -27,4 +27,11 @@ app:layout_constraintHorizontal_bias="0.5" app:layout_constraintStart_toStartOf="parent" /> + + diff --git a/core/src/main/java/org/openedx/core/AppUpdateState.kt b/core/src/main/java/org/openedx/core/AppUpdateState.kt index 0f92d145b..9c016581d 100644 --- a/core/src/main/java/org/openedx/core/AppUpdateState.kt +++ b/core/src/main/java/org/openedx/core/AppUpdateState.kt @@ -3,23 +3,29 @@ package org.openedx.core import android.content.ActivityNotFoundException import android.content.Context import android.content.Intent -import android.net.Uri import androidx.compose.runtime.mutableStateOf +import androidx.core.net.toUri import org.openedx.core.system.notifier.app.AppUpgradeEvent object AppUpdateState { var wasUpdateDialogDisplayed = false - var wasUpdateDialogClosed = mutableStateOf(false) + var wasUpgradeDialogClosed = mutableStateOf(false) + var lastAppUpgradeEvent: AppUpgradeEvent? = null fun openPlayMarket(context: Context) { try { - context.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse("market://details?id=${context.packageName}"))) + context.startActivity( + Intent( + Intent.ACTION_VIEW, + "market://details?id=${context.packageName}".toUri() + ) + ) } catch (e: ActivityNotFoundException) { e.printStackTrace() context.startActivity( Intent( Intent.ACTION_VIEW, - Uri.parse("https://play.google.com/store/apps/details?id=${context.packageName}") + "https://play.google.com/store/apps/details?id=${context.packageName}".toUri() ) ) } @@ -27,7 +33,7 @@ object AppUpdateState { data class AppUpgradeParameters( val appUpgradeEvent: AppUpgradeEvent? = null, - val wasUpdateDialogClosed: Boolean = AppUpdateState.wasUpdateDialogClosed.value, + val wasUpgradeDialogClosed: Boolean = AppUpdateState.wasUpgradeDialogClosed.value, val appUpgradeRecommendedDialog: () -> Unit = {}, val onAppUpgradeRecommendedBoxClick: () -> Unit = {}, val onAppUpgradeRequired: () -> Unit = {}, diff --git a/core/src/main/java/org/openedx/core/presentation/dialog/appupgrade/AppUpgradeDialogFragment.kt b/core/src/main/java/org/openedx/core/presentation/dialog/appupgrade/AppUpgradeDialogFragment.kt index 6e7a4c301..a558e8b40 100644 --- a/core/src/main/java/org/openedx/core/presentation/dialog/appupgrade/AppUpgradeDialogFragment.kt +++ b/core/src/main/java/org/openedx/core/presentation/dialog/appupgrade/AppUpgradeDialogFragment.kt @@ -33,12 +33,12 @@ class AppUpgradeDialogFragment : DialogFragment() { } private fun onNotNowClick() { - AppUpdateState.wasUpdateDialogClosed.value = true + AppUpdateState.wasUpgradeDialogClosed.value = true dismiss() } private fun onUpdateClick() { - AppUpdateState.wasUpdateDialogClosed.value = true + AppUpdateState.wasUpgradeDialogClosed.value = true dismiss() AppUpdateState.openPlayMarket(requireContext()) } diff --git a/dashboard/src/main/java/org/openedx/dashboard/presentation/DashboardListFragment.kt b/dashboard/src/main/java/org/openedx/dashboard/presentation/DashboardListFragment.kt index 642f6257a..55f995a01 100644 --- a/dashboard/src/main/java/org/openedx/dashboard/presentation/DashboardListFragment.kt +++ b/dashboard/src/main/java/org/openedx/dashboard/presentation/DashboardListFragment.kt @@ -73,7 +73,6 @@ import coil.compose.AsyncImage import coil.request.ImageRequest import org.koin.android.ext.android.inject import org.koin.androidx.viewmodel.ext.android.viewModel -import org.openedx.core.AppUpdateState import org.openedx.core.domain.model.Certificate import org.openedx.core.domain.model.CourseAssignments import org.openedx.core.domain.model.CourseSharingUtmParameters @@ -82,8 +81,6 @@ 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.appupgrade.AppUpgradeRecommendedBox -import org.openedx.core.system.notifier.app.AppUpgradeEvent import org.openedx.core.ui.HandleUIMessage import org.openedx.core.ui.OfflineModeDialog import org.openedx.core.ui.displayCutoutForLandscape @@ -127,7 +124,6 @@ class DashboardListFragment : Fragment() { val uiMessage by viewModel.uiMessage.observeAsState() val refreshing by viewModel.updating.observeAsState(false) val canLoadMore by viewModel.canLoadMore.observeAsState(false) - val appUpgradeEvent by viewModel.appUpgradeEvent.observeAsState() DashboardListView( windowSize = windowSize, @@ -154,12 +150,6 @@ class DashboardListFragment : Fragment() { paginationCallback = { viewModel.fetchMore() }, - appUpgradeParameters = AppUpdateState.AppUpgradeParameters( - appUpgradeEvent = appUpgradeEvent, - onAppUpgradeRecommendedBoxClick = { - AppUpdateState.openPlayMarket(requireContext()) - }, - ), ) } } @@ -184,7 +174,6 @@ internal fun DashboardListView( onSwipeRefresh: () -> Unit, paginationCallback: () -> Unit, onItemClick: (EnrolledCourse) -> Unit, - appUpgradeParameters: AppUpdateState.AppUpgradeParameters, ) { val scaffoldState = rememberScaffoldState() val pullRefreshState = @@ -306,7 +295,11 @@ internal fun DashboardListView( } } ) - if (scrollState.shouldLoadMore(firstVisibleIndex, LOAD_MORE_THRESHOLD)) { + if (scrollState.shouldLoadMore( + firstVisibleIndex, + LOAD_MORE_THRESHOLD + ) + ) { paginationCallback() } } @@ -338,17 +331,6 @@ internal fun DashboardListView( .fillMaxWidth() .align(Alignment.BottomCenter) ) { - when (appUpgradeParameters.appUpgradeEvent) { - is AppUpgradeEvent.UpgradeRecommendedEvent -> { - AppUpgradeRecommendedBox( - modifier = Modifier.fillMaxWidth(), - onClick = appUpgradeParameters.onAppUpgradeRecommendedBoxClick - ) - } - - else -> {} - } - if (!isInternetConnectionShown && !hasInternetConnection) { OfflineModeDialog( Modifier @@ -564,7 +546,6 @@ private fun DashboardListViewPreview() { refreshing = false, canLoadMore = false, paginationCallback = {}, - appUpgradeParameters = AppUpdateState.AppUpgradeParameters() ) } } @@ -595,7 +576,6 @@ private fun DashboardListViewTabletPreview() { refreshing = false, canLoadMore = false, paginationCallback = {}, - appUpgradeParameters = AppUpdateState.AppUpgradeParameters() ) } } @@ -617,7 +597,6 @@ private fun EmptyStatePreview() { refreshing = false, canLoadMore = false, paginationCallback = {}, - appUpgradeParameters = AppUpdateState.AppUpgradeParameters() ) } } diff --git a/dashboard/src/main/java/org/openedx/dashboard/presentation/DashboardListViewModel.kt b/dashboard/src/main/java/org/openedx/dashboard/presentation/DashboardListViewModel.kt index e9945f18e..58f83b8f2 100644 --- a/dashboard/src/main/java/org/openedx/dashboard/presentation/DashboardListViewModel.kt +++ b/dashboard/src/main/java/org/openedx/dashboard/presentation/DashboardListViewModel.kt @@ -11,8 +11,6 @@ import org.openedx.core.domain.model.EnrolledCourse 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.app.AppNotifier -import org.openedx.core.system.notifier.app.AppUpgradeEvent import org.openedx.dashboard.domain.interactor.DashboardInteractor import org.openedx.foundation.extension.isInternetError import org.openedx.foundation.presentation.BaseViewModel @@ -27,7 +25,6 @@ class DashboardListViewModel( private val resourceManager: ResourceManager, private val discoveryNotifier: DiscoveryNotifier, private val analytics: DashboardAnalytics, - private val appNotifier: AppNotifier ) : BaseViewModel() { private val coursesList = mutableListOf() @@ -55,10 +52,6 @@ class DashboardListViewModel( val canLoadMore: LiveData get() = _canLoadMore - private val _appUpgradeEvent = MutableLiveData() - val appUpgradeEvent: LiveData - get() = _appUpgradeEvent - override fun onCreate(owner: LifecycleOwner) { super.onCreate(owner) viewModelScope.launch { @@ -72,7 +65,6 @@ class DashboardListViewModel( init { getCourses() - collectAppUpgradeEvent() } fun getCourses() { @@ -168,16 +160,6 @@ class DashboardListViewModel( } } - private fun collectAppUpgradeEvent() { - viewModelScope.launch { - appNotifier.notifier.collect { event -> - if (event is AppUpgradeEvent) { - _appUpgradeEvent.value = event - } - } - } - } - fun dashboardCourseClickedEvent(courseId: String, courseName: String) { analytics.dashboardCourseClickedEvent(courseId, courseName) } diff --git a/dashboard/src/test/java/org/openedx/dashboard/presentation/DashboardListViewModelTest.kt b/dashboard/src/test/java/org/openedx/dashboard/presentation/DashboardListViewModelTest.kt index 4a54b8f36..fae8a9455 100644 --- a/dashboard/src/test/java/org/openedx/dashboard/presentation/DashboardListViewModelTest.kt +++ b/dashboard/src/test/java/org/openedx/dashboard/presentation/DashboardListViewModelTest.kt @@ -8,10 +8,8 @@ import io.mockk.coEvery import io.mockk.coVerify import io.mockk.every import io.mockk.mockk -import io.mockk.verify import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.flow.emptyFlow import kotlinx.coroutines.flow.flow import kotlinx.coroutines.test.StandardTestDispatcher import kotlinx.coroutines.test.advanceUntilIdle @@ -31,7 +29,6 @@ import org.openedx.core.domain.model.Pagination 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.app.AppNotifier import org.openedx.dashboard.domain.interactor.DashboardInteractor import org.openedx.foundation.presentation.UIMessage import org.openedx.foundation.system.ResourceManager @@ -51,7 +48,6 @@ class DashboardListViewModelTest { private val networkConnection = mockk() private val discoveryNotifier = mockk() private val analytics = mockk() - private val appNotifier = mockk() private val noInternet = "Slow or no internet connection" private val somethingWrong = "Something went wrong" @@ -66,7 +62,6 @@ class DashboardListViewModelTest { Dispatchers.setMain(dispatcher) every { resourceManager.getString(R.string.core_error_no_connection) } returns noInternet every { resourceManager.getString(R.string.core_error_unknown_error) } returns somethingWrong - every { appNotifier.notifier } returns emptyFlow() every { config.getApiHostURL() } returns "http://localhost:8000" } @@ -84,7 +79,6 @@ class DashboardListViewModelTest { resourceManager, discoveryNotifier, analytics, - appNotifier ) every { networkConnection.isOnline() } returns true coEvery { interactor.getEnrolledCourses(any()) } throws UnknownHostException() @@ -92,7 +86,6 @@ class DashboardListViewModelTest { coVerify(exactly = 1) { interactor.getEnrolledCourses(any()) } coVerify(exactly = 0) { interactor.getEnrolledCoursesFromCache() } - verify(exactly = 1) { appNotifier.notifier } val message = viewModel.uiMessage.value as? UIMessage.SnackBarMessage assertEquals(noInternet, message?.message) @@ -108,7 +101,6 @@ class DashboardListViewModelTest { resourceManager, discoveryNotifier, analytics, - appNotifier ) every { networkConnection.isOnline() } returns true coEvery { interactor.getEnrolledCourses(any()) } throws Exception() @@ -116,7 +108,6 @@ class DashboardListViewModelTest { coVerify(exactly = 1) { interactor.getEnrolledCourses(any()) } coVerify(exactly = 0) { interactor.getEnrolledCoursesFromCache() } - verify(exactly = 1) { appNotifier.notifier } val message = viewModel.uiMessage.value as? UIMessage.SnackBarMessage assertEquals(somethingWrong, message?.message) @@ -132,7 +123,6 @@ class DashboardListViewModelTest { resourceManager, discoveryNotifier, analytics, - appNotifier ) every { networkConnection.isOnline() } returns true coEvery { interactor.getEnrolledCourses(any()) } returns dashboardCourseList @@ -141,7 +131,6 @@ class DashboardListViewModelTest { coVerify(exactly = 1) { interactor.getEnrolledCourses(any()) } coVerify(exactly = 0) { interactor.getEnrolledCoursesFromCache() } - verify(exactly = 1) { appNotifier.notifier } assert(viewModel.uiMessage.value == null) assert(viewModel.uiState.value is DashboardUIState.Courses) @@ -156,7 +145,6 @@ class DashboardListViewModelTest { resourceManager, discoveryNotifier, analytics, - appNotifier ) every { networkConnection.isOnline() } returns true coEvery { interactor.getEnrolledCourses(any()) } returns dashboardCourseList.copy( @@ -173,7 +161,6 @@ class DashboardListViewModelTest { coVerify(exactly = 1) { interactor.getEnrolledCourses(any()) } coVerify(exactly = 0) { interactor.getEnrolledCoursesFromCache() } - verify(exactly = 1) { appNotifier.notifier } assert(viewModel.uiMessage.value == null) assert(viewModel.uiState.value is DashboardUIState.Courses) @@ -190,14 +177,12 @@ class DashboardListViewModelTest { resourceManager, discoveryNotifier, analytics, - appNotifier ) advanceUntilIdle() coVerify(exactly = 0) { interactor.getEnrolledCourses(any()) } coVerify(exactly = 1) { interactor.getEnrolledCoursesFromCache() } - verify(exactly = 1) { appNotifier.notifier } assert(viewModel.uiMessage.value == null) assert(viewModel.uiState.value is DashboardUIState.Courses) @@ -214,7 +199,6 @@ class DashboardListViewModelTest { resourceManager, discoveryNotifier, analytics, - appNotifier ) coEvery { interactor.getEnrolledCourses(any()) } throws UnknownHostException() @@ -223,7 +207,6 @@ class DashboardListViewModelTest { coVerify(exactly = 2) { interactor.getEnrolledCourses(any()) } coVerify(exactly = 0) { interactor.getEnrolledCoursesFromCache() } - verify(exactly = 1) { appNotifier.notifier } val message = viewModel.uiMessage.value as? UIMessage.SnackBarMessage assertEquals(noInternet, message?.message) @@ -242,7 +225,6 @@ class DashboardListViewModelTest { resourceManager, discoveryNotifier, analytics, - appNotifier ) coEvery { interactor.getEnrolledCourses(any()) } throws Exception() @@ -251,7 +233,6 @@ class DashboardListViewModelTest { coVerify(exactly = 2) { interactor.getEnrolledCourses(any()) } coVerify(exactly = 0) { interactor.getEnrolledCoursesFromCache() } - verify(exactly = 1) { appNotifier.notifier } val message = viewModel.uiMessage.value as? UIMessage.SnackBarMessage assertEquals(somethingWrong, message?.message) @@ -270,7 +251,6 @@ class DashboardListViewModelTest { resourceManager, discoveryNotifier, analytics, - appNotifier ) viewModel.updateCourses() @@ -278,8 +258,6 @@ class DashboardListViewModelTest { coVerify(exactly = 2) { interactor.getEnrolledCourses(any()) } coVerify(exactly = 0) { interactor.getEnrolledCoursesFromCache() } - verify(exactly = 1) { appNotifier.notifier } - assert(viewModel.uiMessage.value == null) assert(viewModel.updating.value == false) assert(viewModel.uiState.value is DashboardUIState.Courses) @@ -303,7 +281,6 @@ class DashboardListViewModelTest { resourceManager, discoveryNotifier, analytics, - appNotifier ) viewModel.updateCourses() @@ -311,8 +288,6 @@ class DashboardListViewModelTest { coVerify(exactly = 2) { interactor.getEnrolledCourses(any()) } coVerify(exactly = 0) { interactor.getEnrolledCoursesFromCache() } - verify(exactly = 1) { appNotifier.notifier } - assert(viewModel.uiMessage.value == null) assert(viewModel.updating.value == false) assert(viewModel.uiState.value is DashboardUIState.Courses) @@ -328,7 +303,6 @@ class DashboardListViewModelTest { resourceManager, discoveryNotifier, analytics, - appNotifier ) val mockLifeCycleOwner: LifecycleOwner = mockk() @@ -339,6 +313,5 @@ class DashboardListViewModelTest { advanceUntilIdle() coVerify(exactly = 1) { interactor.getEnrolledCourses(any()) } - verify(exactly = 1) { appNotifier.notifier } } } diff --git a/discovery/src/main/java/org/openedx/discovery/presentation/NativeDiscoveryFragment.kt b/discovery/src/main/java/org/openedx/discovery/presentation/NativeDiscoveryFragment.kt index 28976b4a7..2212849b5 100644 --- a/discovery/src/main/java/org/openedx/discovery/presentation/NativeDiscoveryFragment.kt +++ b/discovery/src/main/java/org/openedx/discovery/presentation/NativeDiscoveryFragment.kt @@ -57,12 +57,7 @@ import androidx.core.os.bundleOf import androidx.fragment.app.Fragment import org.koin.android.ext.android.inject import org.koin.androidx.viewmodel.ext.android.viewModel -import org.openedx.core.AppUpdateState -import org.openedx.core.AppUpdateState.wasUpdateDialogClosed import org.openedx.core.domain.model.Media -import org.openedx.core.presentation.dialog.appupgrade.AppUpgradeDialogFragment -import org.openedx.core.presentation.global.appupgrade.AppUpgradeRecommendedBox -import org.openedx.core.system.notifier.app.AppUpgradeEvent import org.openedx.core.ui.AuthButtonsPanel import org.openedx.core.ui.BackBtn import org.openedx.core.ui.HandleUIMessage @@ -104,8 +99,6 @@ class NativeDiscoveryFragment : Fragment() { val uiMessage by viewModel.uiMessage.observeAsState() val canLoadMore by viewModel.canLoadMore.observeAsState(false) val refreshing by viewModel.isUpdating.observeAsState(false) - val appUpgradeEvent by viewModel.appUpgradeEvent.observeAsState() - val wasUpdateDialogClosed by remember { wasUpdateDialogClosed } val querySearch = arguments?.getString(ARG_SEARCH_QUERY, "") ?: "" DiscoveryScreen( @@ -119,25 +112,6 @@ class NativeDiscoveryFragment : Fragment() { canShowBackButton = viewModel.canShowBackButton, isUserLoggedIn = viewModel.isUserLoggedIn, isRegistrationEnabled = viewModel.isRegistrationEnabled, - appUpgradeParameters = AppUpdateState.AppUpgradeParameters( - appUpgradeEvent = appUpgradeEvent, - wasUpdateDialogClosed = wasUpdateDialogClosed, - appUpgradeRecommendedDialog = { - val dialog = AppUpgradeDialogFragment.newInstance() - dialog.show( - requireActivity().supportFragmentManager, - AppUpgradeDialogFragment::class.simpleName - ) - }, - onAppUpgradeRecommendedBoxClick = { - AppUpdateState.openPlayMarket(requireContext()) - }, - onAppUpgradeRequired = { - router.navigateToUpgradeRequired( - requireActivity().supportFragmentManager - ) - } - ), onSearchClick = { viewModel.discoverySearchBarClickedEvent() router.navigateToCourseSearch( @@ -214,7 +188,6 @@ internal fun DiscoveryScreen( canShowBackButton: Boolean, isUserLoggedIn: Boolean, isRegistrationEnabled: Boolean, - appUpgradeParameters: AppUpdateState.AppUpgradeParameters, onSearchClick: () -> Unit, onSwipeRefresh: () -> Unit, onReloadClick: () -> Unit, @@ -419,7 +392,11 @@ internal fun DiscoveryScreen( } } } - if (scrollState.shouldLoadMore(firstVisibleIndex, LOAD_MORE_THRESHOLD)) { + if (scrollState.shouldLoadMore( + firstVisibleIndex, + LOAD_MORE_THRESHOLD + ) + ) { paginationCallback() } } @@ -436,30 +413,6 @@ internal fun DiscoveryScreen( .fillMaxWidth() .align(Alignment.BottomCenter) ) { - when (appUpgradeParameters.appUpgradeEvent) { - is AppUpgradeEvent.UpgradeRecommendedEvent -> { - if (appUpgradeParameters.wasUpdateDialogClosed) { - AppUpgradeRecommendedBox( - modifier = Modifier.fillMaxWidth(), - onClick = appUpgradeParameters.onAppUpgradeRecommendedBoxClick - ) - } else { - if (!AppUpdateState.wasUpdateDialogDisplayed) { - AppUpdateState.wasUpdateDialogDisplayed = true - appUpgradeParameters.appUpgradeRecommendedDialog() - } - } - } - - is AppUpgradeEvent.UpgradeRequiredEvent -> { - if (!AppUpdateState.wasUpdateDialogDisplayed) { - AppUpdateState.wasUpdateDialogDisplayed = true - appUpgradeParameters.onAppUpgradeRequired() - } - } - - else -> {} - } if (!isInternetConnectionShown && !hasInternetConnection) { OfflineModeDialog( Modifier @@ -526,7 +479,6 @@ private fun DiscoveryScreenPreview() { hasInternetConnection = true, isUserLoggedIn = false, isRegistrationEnabled = true, - appUpgradeParameters = AppUpdateState.AppUpgradeParameters(), onSignInClick = {}, onRegisterClick = {}, onBackClick = {}, @@ -568,7 +520,6 @@ private fun DiscoveryScreenTabletPreview() { hasInternetConnection = true, isUserLoggedIn = true, isRegistrationEnabled = true, - appUpgradeParameters = AppUpdateState.AppUpgradeParameters(), onSignInClick = {}, onRegisterClick = {}, onBackClick = {}, diff --git a/discovery/src/main/java/org/openedx/discovery/presentation/NativeDiscoveryViewModel.kt b/discovery/src/main/java/org/openedx/discovery/presentation/NativeDiscoveryViewModel.kt index 0d4673e23..70acffbd8 100644 --- a/discovery/src/main/java/org/openedx/discovery/presentation/NativeDiscoveryViewModel.kt +++ b/discovery/src/main/java/org/openedx/discovery/presentation/NativeDiscoveryViewModel.kt @@ -3,15 +3,11 @@ package org.openedx.discovery.presentation import androidx.lifecycle.LiveData import androidx.lifecycle.MutableLiveData import androidx.lifecycle.viewModelScope -import kotlinx.coroutines.FlowPreview -import kotlinx.coroutines.flow.debounce import kotlinx.coroutines.launch import org.openedx.core.R import org.openedx.core.config.Config import org.openedx.core.data.storage.CorePreferences import org.openedx.core.system.connection.NetworkConnection -import org.openedx.core.system.notifier.app.AppNotifier -import org.openedx.core.system.notifier.app.AppUpgradeEvent import org.openedx.discovery.domain.interactor.DiscoveryInteractor import org.openedx.discovery.domain.model.Course import org.openedx.foundation.extension.isInternetError @@ -26,7 +22,6 @@ class NativeDiscoveryViewModel( private val interactor: DiscoveryInteractor, private val resourceManager: ResourceManager, private val analytics: DiscoveryAnalytics, - private val appNotifier: AppNotifier, private val corePreferences: CorePreferences, ) : BaseViewModel() { @@ -51,10 +46,6 @@ class NativeDiscoveryViewModel( val isUpdating: LiveData get() = _isUpdating - private val _appUpgradeEvent = MutableLiveData() - val appUpgradeEvent: LiveData - get() = _appUpgradeEvent - val hasInternetConnection: Boolean get() = networkConnection.isOnline() @@ -64,7 +55,6 @@ class NativeDiscoveryViewModel( init { getCoursesList() - collectAppUpgradeEvent() } private fun loadCoursesInternal( @@ -159,24 +149,6 @@ class NativeDiscoveryViewModel( } } - @OptIn(FlowPreview::class) - private fun collectAppUpgradeEvent() { - viewModelScope.launch { - appNotifier.notifier - .debounce(100) - .collect { event -> - when (event) { - is AppUpgradeEvent.UpgradeRecommendedEvent -> { - _appUpgradeEvent.value = event - } - is AppUpgradeEvent.UpgradeRequiredEvent -> { - _appUpgradeEvent.value = AppUpgradeEvent.UpgradeRequiredEvent - } - } - } - } - } - fun discoverySearchBarClickedEvent() { analytics.discoverySearchBarClickedEvent() } diff --git a/discovery/src/test/java/org/openedx/discovery/presentation/NativeDiscoveryViewModelTest.kt b/discovery/src/test/java/org/openedx/discovery/presentation/NativeDiscoveryViewModelTest.kt index 9a88b445a..d6270fe7b 100644 --- a/discovery/src/test/java/org/openedx/discovery/presentation/NativeDiscoveryViewModelTest.kt +++ b/discovery/src/test/java/org/openedx/discovery/presentation/NativeDiscoveryViewModelTest.kt @@ -5,10 +5,8 @@ import io.mockk.coEvery import io.mockk.coVerify import io.mockk.every import io.mockk.mockk -import io.mockk.verify import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.flow.emptyFlow import kotlinx.coroutines.test.StandardTestDispatcher import kotlinx.coroutines.test.advanceUntilIdle import kotlinx.coroutines.test.resetMain @@ -25,7 +23,6 @@ import org.openedx.core.config.Config import org.openedx.core.data.storage.CorePreferences import org.openedx.core.domain.model.Pagination import org.openedx.core.system.connection.NetworkConnection -import org.openedx.core.system.notifier.app.AppNotifier import org.openedx.discovery.domain.interactor.DiscoveryInteractor import org.openedx.discovery.domain.model.CourseList import org.openedx.foundation.presentation.UIMessage @@ -45,7 +42,6 @@ class NativeDiscoveryViewModelTest { private val interactor = mockk() private val networkConnection = mockk() private val analytics = mockk() - private val appNotifier = mockk() private val corePreferences = mockk() private val noInternet = "Slow or no internet connection" @@ -56,7 +52,6 @@ class NativeDiscoveryViewModelTest { Dispatchers.setMain(dispatcher) every { resourceManager.getString(R.string.core_error_no_connection) } returns noInternet every { resourceManager.getString(R.string.core_error_unknown_error) } returns somethingWrong - every { appNotifier.notifier } returns emptyFlow() every { corePreferences.user } returns null every { config.getApiHostURL() } returns "http://localhost:8000" every { config.isPreLoginExperienceEnabled() } returns false @@ -75,7 +70,6 @@ class NativeDiscoveryViewModelTest { interactor, resourceManager, analytics, - appNotifier, corePreferences ) every { networkConnection.isOnline() } returns true @@ -84,7 +78,6 @@ class NativeDiscoveryViewModelTest { coVerify(exactly = 1) { interactor.getCoursesList(any(), any(), any()) } coVerify(exactly = 0) { interactor.getCoursesListFromCache() } - verify(exactly = 1) { appNotifier.notifier } val message = viewModel.uiMessage.value as? UIMessage.SnackBarMessage assertEquals(noInternet, message?.message) @@ -100,7 +93,6 @@ class NativeDiscoveryViewModelTest { interactor, resourceManager, analytics, - appNotifier, corePreferences ) every { networkConnection.isOnline() } returns true @@ -124,7 +116,6 @@ class NativeDiscoveryViewModelTest { interactor, resourceManager, analytics, - appNotifier, corePreferences ) every { networkConnection.isOnline() } returns false @@ -147,7 +138,6 @@ class NativeDiscoveryViewModelTest { interactor, resourceManager, analytics, - appNotifier, corePreferences ) every { networkConnection.isOnline() } returns true @@ -178,7 +168,6 @@ class NativeDiscoveryViewModelTest { interactor, resourceManager, analytics, - appNotifier, corePreferences ) every { networkConnection.isOnline() } returns true @@ -209,7 +198,6 @@ class NativeDiscoveryViewModelTest { interactor, resourceManager, analytics, - appNotifier, corePreferences ) every { networkConnection.isOnline() } returns true @@ -234,7 +222,6 @@ class NativeDiscoveryViewModelTest { interactor, resourceManager, analytics, - appNotifier, corePreferences ) every { networkConnection.isOnline() } returns true @@ -259,7 +246,6 @@ class NativeDiscoveryViewModelTest { interactor, resourceManager, analytics, - appNotifier, corePreferences ) every { networkConnection.isOnline() } returns true @@ -291,7 +277,6 @@ class NativeDiscoveryViewModelTest { interactor, resourceManager, analytics, - appNotifier, corePreferences ) every { networkConnection.isOnline() } returns true 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 217a35258..f1eaf0aeb 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 @@ -28,12 +28,10 @@ class SettingsFragment : Fragment() { val windowSize = rememberWindowSize() val uiState by viewModel.uiState.collectAsState() val logoutSuccess by viewModel.successLogout.collectAsState(false) - val appUpgradeEvent by viewModel.appUpgradeEvent.collectAsState(null) SettingsScreen( windowSize = windowSize, uiState = uiState, - appUpgradeEvent = appUpgradeEvent, onBackClick = { requireActivity().supportFragmentManager.popBackStack() }, 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 68c773745..6122775bf 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 @@ -50,6 +50,7 @@ import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import androidx.compose.ui.window.Dialog +import org.openedx.core.AppUpdateState import org.openedx.core.R import org.openedx.core.domain.model.AgreementUrls import org.openedx.core.presentation.global.AppData @@ -75,7 +76,6 @@ import org.openedx.profile.R as profileR internal fun SettingsScreen( windowSize: WindowSize, uiState: SettingsUIState, - appUpgradeEvent: AppUpgradeEvent?, onBackClick: () -> Unit, onAction: (SettingsScreenAction) -> Unit, ) { @@ -189,7 +189,6 @@ internal fun SettingsScreen( SupportInfoSection( uiState = uiState, onAction = onAction, - appUpgradeEvent = appUpgradeEvent, ) Spacer(modifier = Modifier.height(24.dp)) @@ -264,7 +263,6 @@ private fun ManageAccountSection(onManageAccountClick: () -> Unit) { @Composable private fun SupportInfoSection( uiState: SettingsUIState.Data, - appUpgradeEvent: AppUpgradeEvent?, onAction: (SettingsScreenAction) -> Unit ) { Column { @@ -325,7 +323,7 @@ private fun SupportInfoSection( } AppVersionItem( versionName = uiState.configuration.versionName, - appUpgradeEvent = appUpgradeEvent, + appUpgradeEvent = AppUpdateState.lastAppUpgradeEvent, ) { onAction(SettingsScreenAction.AppVersionClick) } @@ -692,7 +690,6 @@ private fun SettingsScreenPreview() { windowSize = WindowSize(WindowType.Medium, WindowType.Medium), uiState = mockUiState, onAction = {}, - appUpgradeEvent = null, ) } } 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 59548d1c9..c21f72df3 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 @@ -21,7 +21,6 @@ import org.openedx.core.module.DownloadWorkerController import org.openedx.core.presentation.global.AppData import org.openedx.core.system.AppCookieManager import org.openedx.core.system.notifier.app.AppNotifier -import org.openedx.core.system.notifier.app.AppUpgradeEvent import org.openedx.core.system.notifier.app.LogoutEvent import org.openedx.core.utils.EmailUtil import org.openedx.foundation.extension.isInternetError @@ -62,10 +61,6 @@ class SettingsViewModel( val uiMessage: SharedFlow get() = _uiMessage.asSharedFlow() - private val _appUpgradeEvent = MutableStateFlow(null) - val appUpgradeEvent: StateFlow - get() = _appUpgradeEvent.asStateFlow() - val isLogistrationEnabled get() = config.isPreLoginExperienceEnabled() private val configuration @@ -77,7 +72,6 @@ class SettingsViewModel( ) init { - collectAppUpgradeEvent() collectProfileEvent() } @@ -117,16 +111,6 @@ class SettingsViewModel( } } - private fun collectAppUpgradeEvent() { - viewModelScope.launch { - appNotifier.notifier.collect { event -> - if (event is AppUpgradeEvent) { - _appUpgradeEvent.value = event - } - } - } - } - private fun collectProfileEvent() { viewModelScope.launch { profileNotifier.notifier.collect { From 943381b496759a5cde04e920291d8180fc726ae5 Mon Sep 17 00:00:00 2001 From: Kirill Izmaylov Date: Wed, 9 Apr 2025 12:49:29 +0300 Subject: [PATCH 08/24] fix: primary course in landscape (#435) --- .../presentation/DashboardGalleryView.kt | 272 ++++++++++++------ 1 file changed, 181 insertions(+), 91 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 f95d6dc65..bfa1dc08a 100644 --- a/dashboard/src/main/java/org/openedx/courses/presentation/DashboardGalleryView.kt +++ b/dashboard/src/main/java/org/openedx/courses/presentation/DashboardGalleryView.kt @@ -6,6 +6,7 @@ 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.IntrinsicSize import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer @@ -52,6 +53,7 @@ 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.LocalConfiguration import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.painterResource @@ -61,6 +63,7 @@ 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 androidx.lifecycle.Lifecycle @@ -88,6 +91,7 @@ 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.displayCutoutForLandscape import org.openedx.core.ui.theme.OpenEdXTheme import org.openedx.core.ui.theme.appColors import org.openedx.core.ui.theme.appShapes @@ -200,6 +204,7 @@ private fun DashboardGalleryView( Surface( modifier = Modifier .fillMaxSize() + .displayCutoutForLandscape() .padding(paddingValues), color = MaterialTheme.appColors.background ) { @@ -528,7 +533,7 @@ private fun PrimaryCourseCard( resumeBlockId: (enrolledCourse: EnrolledCourse, blockId: String) -> Unit, openCourse: (EnrolledCourse) -> Unit, ) { - val context = LocalContext.current + val orientation = LocalConfiguration.current.orientation Card( modifier = Modifier .padding(horizontal = 16.dp) @@ -538,103 +543,184 @@ private fun PrimaryCourseCard( shape = MaterialTheme.appShapes.courseImageShape, elevation = 4.dp ) { - Column( - modifier = Modifier - .clickable { - openCourse(primaryCourse) - } - ) { - AsyncImage( - model = ImageRequest.Builder(context) - .data(primaryCourse.course.courseImage.toImageLink(apiHostUrl)) - .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) + when (orientation) { + Configuration.ORIENTATION_LANDSCAPE -> { + Row( + modifier = Modifier + .clickable { + openCourse(primaryCourse) } - }, - painter = rememberVectorPainter(Icons.Default.Warning), - title = title, - info = pluralStringResource( - R.plurals.dashboard_past_due_assignment, - pastAssignments.size, - pastAssignments.size + .height(IntrinsicSize.Min) + ) { + PrimaryCourseCaption( + modifier = Modifier.weight(1f), + primaryCourse = primaryCourse, + apiHostUrl = apiHostUrl, + imageHeight = null, ) - ) + PrimaryCourseButtons( + modifier = Modifier.weight(1f), + primaryCourse = primaryCourse, + navigateToDates = navigateToDates, + resumeBlockId = resumeBlockId, + openCourse = openCourse, + adjustHeight = true, + useRelativeDates = useRelativeDates, + ) + } } - 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( + + else -> { + Column( 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, - nearestAssignment.assignmentType ?: "", - stringResource( - id = CoreR.string.core_date_format_assignment_due, - TimeUtils.formatToString(context, nearestAssignment.date, useRelativeDates) - ) + openCourse(primaryCourse) + } + ) { + PrimaryCourseCaption( + primaryCourse = primaryCourse, + apiHostUrl = apiHostUrl, ) - ) + PrimaryCourseButtons( + primaryCourse = primaryCourse, + navigateToDates = navigateToDates, + resumeBlockId = resumeBlockId, + openCourse = openCourse, + useRelativeDates = useRelativeDates, + ) + } } - ResumeButton( - primaryCourse = primaryCourse, - onClick = { - if (primaryCourse.courseStatus == null) { - openCourse(primaryCourse) + } + } +} + +@Composable +private fun PrimaryCourseButtons( + modifier: Modifier = Modifier, + primaryCourse: EnrolledCourse, + useRelativeDates: Boolean, + adjustHeight: Boolean = false, + navigateToDates: (EnrolledCourse) -> Unit, + resumeBlockId: (enrolledCourse: EnrolledCourse, blockId: String) -> Unit, + openCourse: (EnrolledCourse) -> Unit, +) { + val context = LocalContext.current + val pastAssignments = primaryCourse.courseAssignments?.pastAssignments + Column(modifier = modifier) { + var titleModifier = Modifier + .fillMaxWidth() + .padding(horizontal = 12.dp) + .padding(top = 8.dp, bottom = 16.dp) + if (adjustHeight) { + titleModifier = titleModifier.weight(1f) + } + PrimaryCourseTitle( + modifier = titleModifier, + primaryCourse = primaryCourse, + ) + Divider() + if (!pastAssignments.isNullOrEmpty()) { + val nearestAssignment = pastAssignments.maxBy { it.date } + val title = if (pastAssignments.size == 1) nearestAssignment.title else null + AssignmentItem( + modifier = Modifier.clickable { + if (pastAssignments.size == 1) { + resumeBlockId(primaryCourse, nearestAssignment.blockId) } else { - resumeBlockId(primaryCourse, primaryCourse.courseStatus?.lastVisitedBlockId ?: "") + navigateToDates(primaryCourse) } - } + }, + painter = rememberVectorPainter(Icons.Default.Warning), + title = title, + info = pluralStringResource( + R.plurals.dashboard_past_due_assignment, + pastAssignments.size, + 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, + nearestAssignment.assignmentType ?: "", + stringResource( + id = CoreR.string.core_date_format_assignment_due, + TimeUtils.formatToString(context, nearestAssignment.date, useRelativeDates), + ) + ) ) } + ResumeButton( + primaryCourse = primaryCourse, + onClick = { + if (primaryCourse.courseStatus == null) { + openCourse(primaryCourse) + } else { + resumeBlockId( + primaryCourse, + primaryCourse.courseStatus?.lastVisitedBlockId ?: "" + ) + } + } + ) + } +} + +@Composable +private fun PrimaryCourseCaption( + modifier: Modifier = Modifier, + primaryCourse: EnrolledCourse, + imageHeight: Dp? = 140.dp, + apiHostUrl: String, +) { + val context = LocalContext.current + Column(modifier = modifier) { + val imageModifier = imageHeight?.let { + Modifier + .height(it) + .fillMaxWidth() + } ?: Modifier + .height(IntrinsicSize.Max) + .fillMaxWidth() + .weight(1f) + + AsyncImage( + model = ImageRequest.Builder(context) + .data(primaryCourse.course.courseImage.toImageLink(apiHostUrl)) + .error(CoreR.drawable.core_no_image_course) + .placeholder(CoreR.drawable.core_no_image_course) + .build(), + contentDescription = null, + contentScale = ContentScale.Crop, + modifier = imageModifier, + ) + 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 + ) } } @@ -704,7 +790,7 @@ private fun PrimaryCourseTitle( ) { Column( modifier = modifier, - verticalArrangement = Arrangement.spacedBy(4.dp) + verticalArrangement = Arrangement.Center ) { Text( modifier = Modifier.fillMaxWidth(), @@ -713,7 +799,9 @@ private fun PrimaryCourseTitle( color = MaterialTheme.appColors.textFieldHint ) Text( - modifier = Modifier.fillMaxWidth(), + modifier = Modifier + .fillMaxWidth() + .padding(top = 4.dp), text = primaryCourse.course.name, style = MaterialTheme.appTypography.titleLarge, color = MaterialTheme.appColors.textDark, @@ -721,7 +809,9 @@ private fun PrimaryCourseTitle( maxLines = 3 ) Text( - modifier = Modifier.fillMaxWidth(), + modifier = Modifier + .fillMaxWidth() + .padding(top = 4.dp), style = MaterialTheme.appTypography.labelMedium, color = MaterialTheme.appColors.textFieldHint, text = TimeUtils.getCourseFormattedDate( From 0b254f776d81be47edca694f52a68469f6e5c183 Mon Sep 17 00:00:00 2001 From: PavloNetrebchuk <141041606+PavloNetrebchuk@users.noreply.github.com> Date: Wed, 9 Apr 2025 23:13:07 +0300 Subject: [PATCH 09/24] fix: AllEnrolledCoursesView and DashboardGalleryView tablet layout paddings (#433) --- .../presentation/AllEnrolledCoursesView.kt | 84 +++++----- .../presentation/DashboardGalleryView.kt | 145 +++++++++++------- .../download/DownloadsViewModel.kt | 3 +- .../downloads/DownloadsViewModelTest.kt | 1 + 4 files changed, 132 insertions(+), 101 deletions(-) diff --git a/dashboard/src/main/java/org/openedx/courses/presentation/AllEnrolledCoursesView.kt b/dashboard/src/main/java/org/openedx/courses/presentation/AllEnrolledCoursesView.kt index 9d26e39df..5fc19dcbb 100644 --- a/dashboard/src/main/java/org/openedx/courses/presentation/AllEnrolledCoursesView.kt +++ b/dashboard/src/main/java/org/openedx/courses/presentation/AllEnrolledCoursesView.kt @@ -230,7 +230,7 @@ private fun AllEnrolledCoursesView( val contentWidth by remember(key1 = windowSize) { mutableStateOf( windowSize.windowSizeValue( - expanded = Modifier.widthIn(Dp.Unspecified, 650.dp), + expanded = Modifier.widthIn(Dp.Unspecified, 560.dp), compact = Modifier.fillMaxWidth(), ) ) @@ -274,7 +274,9 @@ private fun AllEnrolledCoursesView( Header( modifier = Modifier .padding( - start = contentPaddings.calculateStartPadding(layoutDirection), + start = contentPaddings.calculateStartPadding( + layoutDirection + ), end = contentPaddings.calculateEndPadding(layoutDirection) ), onSearchClick = { @@ -305,50 +307,52 @@ private fun AllEnrolledCoursesView( !state.courses.isNullOrEmpty() -> { Box( - modifier = Modifier - .fillMaxSize() - .padding(contentPaddings), - contentAlignment = Alignment.Center + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.TopCenter ) { - 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(span = { GridItemSpan(columns) }) { - if (state.canLoadMore) { - Box( - modifier = Modifier - .fillMaxWidth() - .height(180.dp), - contentAlignment = Alignment.Center - ) { - CircularProgressIndicator( - color = MaterialTheme.appColors.primary + LazyVerticalGrid( + modifier = Modifier + .fillMaxHeight(), + state = scrollState, + columns = GridCells.Fixed(columns), + verticalArrangement = Arrangement.spacedBy(12.dp), + horizontalArrangement = Arrangement.spacedBy(12.dp), + contentPadding = contentPaddings, + content = { + items(state.courses) { course -> + CourseItem( + course = course, + apiHostUrl = apiHostUrl, + onClick = { + onAction( + AllEnrolledCoursesAction.OpenCourse( + it ) - } + ) + } + ) + } + item(span = { GridItemSpan(columns) }) { + if (state.canLoadMore) { + Box( + modifier = Modifier + .fillMaxWidth() + .height(180.dp), + contentAlignment = Alignment.Center + ) { + CircularProgressIndicator( + color = MaterialTheme.appColors.primary + ) } } } + } + ) + if (scrollState.shouldLoadMore( + firstVisibleIndex, + LOAD_MORE_THRESHOLD ) - } - if (scrollState.shouldLoadMore(firstVisibleIndex, LOAD_MORE_THRESHOLD)) { + ) { onAction(AllEnrolledCoursesAction.EndOfPage) } } 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 bfa1dc08a..71859d16c 100644 --- a/dashboard/src/main/java/org/openedx/courses/presentation/DashboardGalleryView.kt +++ b/dashboard/src/main/java/org/openedx/courses/presentation/DashboardGalleryView.kt @@ -18,6 +18,7 @@ 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.layout.widthIn import androidx.compose.foundation.lazy.grid.GridCells import androidx.compose.foundation.lazy.grid.LazyHorizontalGrid import androidx.compose.foundation.lazy.grid.items @@ -46,6 +47,7 @@ import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment @@ -103,6 +105,7 @@ import org.openedx.dashboard.R import org.openedx.foundation.extension.toImageLink import org.openedx.foundation.presentation.UIMessage import org.openedx.foundation.presentation.rememberWindowSize +import org.openedx.foundation.presentation.windowSizeValue import java.util.Date import org.openedx.core.R as CoreR @@ -184,6 +187,7 @@ private fun DashboardGalleryView( onAction: (DashboardGalleryScreenAction) -> Unit, hasInternetConnection: Boolean ) { + val windowSize = rememberWindowSize() val scaffoldState = rememberScaffoldState() val pullRefreshState = rememberPullRefreshState( refreshing = updating, @@ -193,6 +197,24 @@ private fun DashboardGalleryView( mutableStateOf(false) } + val contentWidth by remember(key1 = windowSize) { + mutableStateOf( + windowSize.windowSizeValue( + expanded = Modifier.widthIn(Dp.Unspecified, 560.dp), + compact = Modifier.fillMaxWidth(), + ) + ) + } + + val contentPadding by remember(key1 = windowSize) { + mutableStateOf( + windowSize.windowSizeValue( + expanded = PaddingValues(0.dp), + compact = PaddingValues(horizontal = 16.dp) + ) + ) + } + Scaffold( scaffoldState = scaffoldState, modifier = Modifier.fillMaxSize(), @@ -209,64 +231,66 @@ private fun DashboardGalleryView( color = MaterialTheme.appColors.background ) { Box( - Modifier.fillMaxSize() + Modifier + .fillMaxSize() + .pullRefresh(pullRefreshState) + .verticalScroll(rememberScrollState()), ) { - 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, - useRelativeDates = uiState.useRelativeDates, - apiHostUrl = apiHostUrl, - openCourse = { - onAction(DashboardGalleryScreenAction.OpenCourse(it)) - }, - onViewAllClick = { - onAction(DashboardGalleryScreenAction.ViewAll) - }, - navigateToDates = { - onAction(DashboardGalleryScreenAction.NavigateToDates(it)) - }, - resumeBlockId = { course, blockId -> - onAction(DashboardGalleryScreenAction.OpenBlock(course, blockId)) - } - ) - } + when (uiState) { + is DashboardGalleryUIState.Loading -> { + CircularProgressIndicator( + modifier = Modifier.align(Alignment.Center), + color = MaterialTheme.appColors.primary + ) + } - is DashboardGalleryUIState.Empty -> { - NoCoursesInfo( - modifier = Modifier - .align(Alignment.Center) - ) - FindACourseButton( - modifier = Modifier - .align(Alignment.BottomCenter), - findACourseClick = { - onAction(DashboardGalleryScreenAction.NavigateToDiscovery) - } - ) - } + is DashboardGalleryUIState.Courses -> { + UserCourses( + modifier = contentWidth + .fillMaxHeight() + .padding(vertical = 12.dp) + .displayCutoutForLandscape() + .align(Alignment.TopCenter), + contentPadding = contentPadding, + userCourses = uiState.userCourses, + useRelativeDates = uiState.useRelativeDates, + apiHostUrl = apiHostUrl, + openCourse = { + onAction(DashboardGalleryScreenAction.OpenCourse(it)) + }, + onViewAllClick = { + onAction(DashboardGalleryScreenAction.ViewAll) + }, + navigateToDates = { + onAction(DashboardGalleryScreenAction.NavigateToDates(it)) + }, + resumeBlockId = { course, blockId -> + onAction(DashboardGalleryScreenAction.OpenBlock(course, blockId)) + } + ) } - PullRefreshIndicator( - updating, - pullRefreshState, - Modifier.align(Alignment.TopCenter) - ) + 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 @@ -290,6 +314,7 @@ private fun DashboardGalleryView( private fun UserCourses( modifier: Modifier = Modifier, userCourses: CourseEnrollments, + contentPadding: PaddingValues, apiHostUrl: String, useRelativeDates: Boolean, openCourse: (EnrolledCourse) -> Unit, @@ -299,11 +324,11 @@ private fun UserCourses( ) { Column( modifier = modifier - .padding(vertical = 12.dp) ) { val primaryCourse = userCourses.primary if (primaryCourse != null) { PrimaryCourseCard( + modifier = Modifier.padding(contentPadding), primaryCourse = primaryCourse, apiHostUrl = apiHostUrl, navigateToDates = navigateToDates, @@ -317,6 +342,7 @@ private fun UserCourses( courses = userCourses.enrollments.courses, hasNextPage = userCourses.enrollments.pagination.next.isNotEmpty(), apiHostUrl = apiHostUrl, + contentPadding = contentPadding, onCourseClick = openCourse, onViewAllClick = onViewAllClick ) @@ -329,6 +355,7 @@ private fun SecondaryCourses( courses: List, hasNextPage: Boolean, apiHostUrl: String, + contentPadding: PaddingValues, onCourseClick: (EnrolledCourse) -> Unit, onViewAllClick: () -> Unit ) { @@ -348,7 +375,7 @@ private fun SecondaryCourses( verticalArrangement = Arrangement.spacedBy(8.dp) ) { TextIcon( - modifier = Modifier.padding(horizontal = 18.dp), + modifier = Modifier.padding(contentPadding), text = stringResource(R.string.dashboard_view_all_with_count, courses.size + 1), textStyle = MaterialTheme.appTypography.titleSmall, icon = Icons.AutoMirrored.Filled.KeyboardArrowRight, @@ -361,7 +388,7 @@ private fun SecondaryCourses( .fillMaxSize() .height(height), rows = GridCells.Fixed(rows), - contentPadding = PaddingValues(horizontal = 18.dp), + contentPadding = contentPadding, content = { items(items) { CourseListItem( @@ -526,6 +553,7 @@ private fun AssignmentItem( @Composable private fun PrimaryCourseCard( + modifier: Modifier = Modifier, primaryCourse: EnrolledCourse, apiHostUrl: String, useRelativeDates: Boolean, @@ -535,8 +563,7 @@ private fun PrimaryCourseCard( ) { val orientation = LocalConfiguration.current.orientation Card( - modifier = Modifier - .padding(horizontal = 16.dp) + modifier = modifier .fillMaxWidth() .padding(2.dp), backgroundColor = MaterialTheme.appColors.background, @@ -709,7 +736,7 @@ private fun PrimaryCourseCaption( ) val progress: Float = try { primaryCourse.progress.assignmentsCompleted.toFloat() / - primaryCourse.progress.totalAssignmentsCount.toFloat() + primaryCourse.progress.totalAssignmentsCount.toFloat() } catch (_: ArithmeticException) { 0f } diff --git a/downloads/src/main/java/org/openedx/downloads/presentation/download/DownloadsViewModel.kt b/downloads/src/main/java/org/openedx/downloads/presentation/download/DownloadsViewModel.kt index bfa1037ef..24381a2a5 100644 --- a/downloads/src/main/java/org/openedx/downloads/presentation/download/DownloadsViewModel.kt +++ b/downloads/src/main/java/org/openedx/downloads/presentation/download/DownloadsViewModel.kt @@ -4,7 +4,6 @@ import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.School import androidx.fragment.app.FragmentManager import androidx.lifecycle.viewModelScope -import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow @@ -171,7 +170,7 @@ class DownloadsViewModel( } private fun fetchDownloads(refresh: Boolean) { - viewModelScope.launch(Dispatchers.IO) { + viewModelScope.launch { updateLoadingState(isLoading = !refresh, isRefreshing = refresh) interactor.getDownloadCoursesPreview(refresh) .onCompletion { diff --git a/downloads/src/test/java/org/openedx/downloads/DownloadsViewModelTest.kt b/downloads/src/test/java/org/openedx/downloads/DownloadsViewModelTest.kt index e9476fbcb..5e1622352 100644 --- a/downloads/src/test/java/org/openedx/downloads/DownloadsViewModelTest.kt +++ b/downloads/src/test/java/org/openedx/downloads/DownloadsViewModelTest.kt @@ -265,6 +265,7 @@ class DownloadsViewModelTest { workerController, downloadHelper ) + advanceUntilIdle() val fragmentManager = mockk(relaxed = true) viewModel.downloadCourse(fragmentManager, "course1") advanceUntilIdle() From 6e59c7fe770bf7747081232bd5abbae468fc6539 Mon Sep 17 00:00:00 2001 From: Volodymyr Chekyrta Date: Tue, 13 May 2025 12:55:11 +0300 Subject: [PATCH 10/24] fix: ProgressBarRangeInfo IllegalArgumentException - current must not be NaN (#443) --- .../org/openedx/core/domain/model/Progress.kt | 7 ++----- .../org/openedx/core/extension/FloatExt.kt | 19 +++++++++++++++++++ .../offline/CourseOfflineViewModel.kt | 5 +++-- .../course/presentation/ui/CourseUI.kt | 7 ++----- .../presentation/AllEnrolledCoursesView.kt | 7 +------ .../presentation/DashboardGalleryView.kt | 8 +------- .../presentation/download/DownloadsScreen.kt | 7 ++----- 7 files changed, 30 insertions(+), 30 deletions(-) create mode 100644 core/src/main/java/org/openedx/core/extension/FloatExt.kt 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 800a9c292..edbcf0f90 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 @@ -3,6 +3,7 @@ package org.openedx.core.domain.model import android.os.Parcelable import kotlinx.parcelize.IgnoredOnParcel import kotlinx.parcelize.Parcelize +import org.openedx.core.extension.safeDivBy @Parcelize data class Progress( @@ -11,11 +12,7 @@ data class Progress( ) : Parcelable { @IgnoredOnParcel - val value: Float = try { - assignmentsCompleted.toFloat() / totalAssignmentsCount.toFloat() - } catch (_: ArithmeticException) { - 0f - } + val value: Float = assignmentsCompleted.toFloat().safeDivBy(totalAssignmentsCount.toFloat()) companion object { val DEFAULT_PROGRESS = Progress(0, 0) diff --git a/core/src/main/java/org/openedx/core/extension/FloatExt.kt b/core/src/main/java/org/openedx/core/extension/FloatExt.kt new file mode 100644 index 000000000..77a022736 --- /dev/null +++ b/core/src/main/java/org/openedx/core/extension/FloatExt.kt @@ -0,0 +1,19 @@ +package org.openedx.core.extension + +/** + * Safely divides this Float by [divisor], returning 0f if: + * - [divisor] is zero, + * - the result is NaN. + * + * Workaround for accessibility issue: + * https://github.com/openedx/openedx-app-android/issues/442 + */ +fun Float.safeDivBy(divisor: Float): Float = try { + var result = this / divisor + if (result.isNaN()) { + result = 0f + } + result +} catch (_: ArithmeticException) { + 0f +} diff --git a/course/src/main/java/org/openedx/course/presentation/offline/CourseOfflineViewModel.kt b/course/src/main/java/org/openedx/course/presentation/offline/CourseOfflineViewModel.kt index 8f3637b24..497ba799d 100644 --- a/course/src/main/java/org/openedx/course/presentation/offline/CourseOfflineViewModel.kt +++ b/course/src/main/java/org/openedx/course/presentation/offline/CourseOfflineViewModel.kt @@ -14,6 +14,7 @@ import kotlinx.coroutines.launch import org.openedx.core.BlockType import org.openedx.core.data.storage.CorePreferences import org.openedx.core.domain.model.Block +import org.openedx.core.extension.safeDivBy import org.openedx.core.module.DownloadWorkerController import org.openedx.core.module.db.DownloadDao import org.openedx.core.module.db.DownloadModel @@ -187,12 +188,12 @@ class CourseOfflineViewModel( completedDownloads: List, downloadedBlocks: List ) { - val downloadedSize = getFilesSize(downloadedBlocks) + val downloadedSize = getFilesSize(downloadedBlocks).toFloat() val realDownloadedSize = completedDownloads.sumOf { it.size } val largestDownloads = completedDownloads .sortedByDescending { it.size } .take(n = 5) - val progressBarValue = downloadedSize.toFloat() / totalDownloadableSize.toFloat() + val progressBarValue = downloadedSize.safeDivBy(totalDownloadableSize.toFloat()) val readyToDownloadSize = if (progressBarValue >= 1) { 0 } else { 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 695049c75..e713c7d14 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 @@ -84,6 +84,7 @@ 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.safeDivBy import org.openedx.core.module.db.DownloadModel import org.openedx.core.module.db.DownloadedState import org.openedx.core.module.db.FileType @@ -260,11 +261,7 @@ fun OfflineQueueCard( maxLines = 1 ) - val progress = if (progressSize == 0L) { - 0f - } else { - progressValue.toFloat() / progressSize - } + val progress = progressValue.toFloat().safeDivBy(progressSize.toFloat()) LinearProgressIndicator( modifier = Modifier .fillMaxWidth() diff --git a/dashboard/src/main/java/org/openedx/courses/presentation/AllEnrolledCoursesView.kt b/dashboard/src/main/java/org/openedx/courses/presentation/AllEnrolledCoursesView.kt index 5fc19dcbb..c0967b5d0 100644 --- a/dashboard/src/main/java/org/openedx/courses/presentation/AllEnrolledCoursesView.kt +++ b/dashboard/src/main/java/org/openedx/courses/presentation/AllEnrolledCoursesView.kt @@ -435,16 +435,11 @@ fun CourseItem( .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, + progress = course.progress.value, color = MaterialTheme.appColors.primary, backgroundColor = MaterialTheme.appColors.divider ) 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 71859d16c..aae42e985 100644 --- a/dashboard/src/main/java/org/openedx/courses/presentation/DashboardGalleryView.kt +++ b/dashboard/src/main/java/org/openedx/courses/presentation/DashboardGalleryView.kt @@ -734,17 +734,11 @@ private fun PrimaryCourseCaption( contentScale = ContentScale.Crop, modifier = imageModifier, ) - val progress: Float = try { - primaryCourse.progress.assignmentsCompleted.toFloat() / - primaryCourse.progress.totalAssignmentsCount.toFloat() - } catch (_: ArithmeticException) { - 0f - } LinearProgressIndicator( modifier = Modifier .fillMaxWidth() .height(8.dp), - progress = progress, + progress = primaryCourse.progress.value, color = MaterialTheme.appColors.primary, backgroundColor = MaterialTheme.appColors.divider ) diff --git a/downloads/src/main/java/org/openedx/downloads/presentation/download/DownloadsScreen.kt b/downloads/src/main/java/org/openedx/downloads/presentation/download/DownloadsScreen.kt index fafa04f94..e633368b3 100644 --- a/downloads/src/main/java/org/openedx/downloads/presentation/download/DownloadsScreen.kt +++ b/downloads/src/main/java/org/openedx/downloads/presentation/download/DownloadsScreen.kt @@ -71,6 +71,7 @@ import androidx.compose.ui.unit.dp import coil.compose.AsyncImage import coil.request.ImageRequest import org.openedx.core.domain.model.DownloadCoursePreview +import org.openedx.core.extension.safeDivBy import org.openedx.core.module.db.DownloadModel import org.openedx.core.module.db.DownloadedState import org.openedx.core.module.db.DownloadedState.LOADING_COURSE_STRUCTURE @@ -285,11 +286,7 @@ private fun CourseItem( .sumOf { it.size } val availableSize = downloadCoursePreview.totalSize - downloadedSize val availableSizeString = availableSize.toFileSize(space = false, round = 1) - val progress: Float = try { - downloadedSize.toFloat() / downloadCoursePreview.totalSize.toFloat() - } catch (_: ArithmeticException) { - 0f - } + val progress = downloadedSize.toFloat().safeDivBy(downloadCoursePreview.totalSize.toFloat()) Card( modifier = modifier .fillMaxWidth(), From 7b5846aed1e7c68426acff6ede4e81d01824b5c6 Mon Sep 17 00:00:00 2001 From: Volodymyr Chekyrta Date: Thu, 15 May 2025 20:51:26 +0300 Subject: [PATCH 11/24] fix: fetch future course dates for calendar (#444) --- core/src/main/java/org/openedx/core/data/api/CourseApi.kt | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/core/src/main/java/org/openedx/core/data/api/CourseApi.kt b/core/src/main/java/org/openedx/core/data/api/CourseApi.kt index 50cd81d6b..0790e3eba 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 @@ -61,7 +61,10 @@ interface CourseApi { ) @GET("/api/course_home/v1/dates/{course_id}") - suspend fun getCourseDates(@Path("course_id") courseId: String): CourseDates + suspend fun getCourseDates( + @Path("course_id") courseId: String, + @Query("allow_not_started_courses") allowNotStartedCourses: Boolean = true + ): CourseDates @POST("/api/course_experience/v1/reset_course_deadlines") suspend fun resetCourseDates(@Body courseBody: Map): ResetCourseDates From b126701823d94637dcfb049cffa045a5cde8ab2b Mon Sep 17 00:00:00 2001 From: Volodymyr Chekyrta Date: Wed, 16 Jul 2025 11:46:00 +0300 Subject: [PATCH 12/24] chore: bump foundation version (#449) --- app/build.gradle | 2 +- core/build.gradle | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/app/build.gradle b/app/build.gradle index 2c17ea1c1..360ca6438 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -138,7 +138,7 @@ dependencies { implementation "com.braze:android-sdk-ui:30.2.0" // Plugins - implementation("com.github.openedx:openedx-app-firebase-analytics-android:1.0.0") + implementation("com.github.openedx:openedx-app-firebase-analytics-android:1.0.1") androidTestImplementation 'androidx.test.ext:junit:1.2.1' androidTestImplementation 'androidx.test.espresso:espresso-core:3.6.1' diff --git a/core/build.gradle b/core/build.gradle index db0ce4bb1..2c2ac0858 100644 --- a/core/build.gradle +++ b/core/build.gradle @@ -117,7 +117,7 @@ dependencies { api "net.lingala.zip4j:zip4j:$zip_version" // OpenEdx libs - api("com.github.openedx:openedx-app-foundation-android:1.0.0") + api("com.github.openedx:openedx-app-foundation-android:1.0.1") // Preview debugApi "androidx.compose.ui:ui-tooling:$compose_ui_tooling" From c8fc1816ef11f4571690c398ea62ff48595782a8 Mon Sep 17 00:00:00 2001 From: PavloNetrebchuk <141041606+PavloNetrebchuk@users.noreply.github.com> Date: Tue, 29 Jul 2025 09:56:39 +0300 Subject: [PATCH 13/24] fix: removing video after user logs out (#451) --- .../java/org/openedx/core/module/download/AbstractDownloader.kt | 1 + 1 file changed, 1 insertion(+) diff --git a/core/src/main/java/org/openedx/core/module/download/AbstractDownloader.kt b/core/src/main/java/org/openedx/core/module/download/AbstractDownloader.kt index d2c6d8c74..86fac4271 100644 --- a/core/src/main/java/org/openedx/core/module/download/AbstractDownloader.kt +++ b/core/src/main/java/org/openedx/core/module/download/AbstractDownloader.kt @@ -73,6 +73,7 @@ abstract class AbstractDownloader : KoinComponent { private fun closeResources() { fos?.close() input?.close() + currentDownloadingFilePath = null } suspend fun cancelDownloading() { From b6405a0c79922f42416f9d2876b1cd1ac27659f9 Mon Sep 17 00:00:00 2001 From: PavloNetrebchuk <141041606+PavloNetrebchuk@users.noreply.github.com> Date: Thu, 31 Jul 2025 09:48:51 +0300 Subject: [PATCH 14/24] fix: refactored and fixed memory leak in isImeVisibleState function (#452) --- .../org/openedx/core/ui/ComposeExtensions.kt | 32 +++++++------------ 1 file changed, 11 insertions(+), 21 deletions(-) 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 b30746fe3..1351662eb 100644 --- a/core/src/main/java/org/openedx/core/ui/ComposeExtensions.kt +++ b/core/src/main/java/org/openedx/core/ui/ComposeExtensions.kt @@ -1,16 +1,16 @@ package org.openedx.core.ui import android.content.res.Configuration -import android.graphics.Rect -import android.view.ViewTreeObserver import androidx.compose.foundation.clickable import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.ime 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 +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.MutableState import androidx.compose.runtime.ReadOnlyComposable import androidx.compose.runtime.Stable @@ -34,7 +34,6 @@ import androidx.compose.ui.platform.LocalConfiguration import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalDensity 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 @@ -156,25 +155,16 @@ fun rememberSaveableMap(init: () -> MutableMap): MutableMa } @Composable -fun isImeVisibleState(): State { - val keyboardState = remember { mutableStateOf(false) } - val view = LocalView.current - DisposableEffect(view) { - val onGlobalListener = ViewTreeObserver.OnGlobalLayoutListener { - val rect = Rect() - view.getWindowVisibleDisplayFrame(rect) - val screenHeight = view.rootView.height - val keypadHeight = screenHeight - rect.bottom - keyboardState.value = keypadHeight > screenHeight * KEYBOARD_VISIBILITY_THRESHOLD - } - view.viewTreeObserver.addOnGlobalLayoutListener(onGlobalListener) - - onDispose { - view.viewTreeObserver.removeOnGlobalLayoutListener(onGlobalListener) - } +fun isImeVisibleState(threshold: Int = 0): State { + val imeInsets = WindowInsets.ime + val imeBottom = imeInsets.getBottom(LocalDensity.current) + val isOpen = remember(imeBottom) { mutableStateOf(false) } + + LaunchedEffect(imeBottom) { + isOpen.value = imeBottom > threshold } - return keyboardState + return isOpen } fun PagerState.calculateCurrentOffsetForPage(page: Int): Float { From 40592825bdfb45f108f96e04f813a6faaefeafb7 Mon Sep 17 00:00:00 2001 From: PavloNetrebchuk <141041606+PavloNetrebchuk@users.noreply.github.com> Date: Thu, 31 Jul 2025 18:34:32 +0300 Subject: [PATCH 15/24] fix: MainFragment pager memory leaks (#453) --- .../main/java/org/openedx/app/MainFragment.kt | 46 +++++++++---------- .../core/adapter/NavigationFragmentAdapter.kt | 10 ++-- .../learn/presentation/LearnFragment.kt | 4 +- 3 files changed, 30 insertions(+), 30 deletions(-) diff --git a/app/src/main/java/org/openedx/app/MainFragment.kt b/app/src/main/java/org/openedx/app/MainFragment.kt index c58ec437f..82092e439 100644 --- a/app/src/main/java/org/openedx/app/MainFragment.kt +++ b/app/src/main/java/org/openedx/app/MainFragment.kt @@ -39,8 +39,6 @@ class MainFragment : Fragment(R.layout.fragment_main) { private val viewModel by viewModel() private val router by inject() - private lateinit var adapter: NavigationFragmentAdapter - override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) lifecycle.addObserver(viewModel) @@ -89,26 +87,28 @@ class MainFragment : Fragment(R.layout.fragment_main) { requireArguments().remove(ARG_OPEN_TAB) } - private fun createTabList(openTabArg: String): List> { - val learnFragment = LearnFragment.newInstance( - openTab = if (openTabArg == HomeTab.PROGRAMS.name) { - LearnTab.PROGRAMS.name - } else { - LearnTab.COURSES.name - } - ) + private fun createTabList(openTabArg: String): List Fragment>> { + val learnFragmentFactory = { + LearnFragment.newInstance( + openTab = if (openTabArg == HomeTab.PROGRAMS.name) { + LearnTab.PROGRAMS.name + } else { + LearnTab.COURSES.name + } + ) + } - return mutableListOf>().apply { - add(R.id.fragmentLearn to learnFragment) - add(R.id.fragmentDiscover to viewModel.getDiscoveryFragment) + return mutableListOf Fragment>>().apply { + add(R.id.fragmentLearn to learnFragmentFactory) + add(R.id.fragmentDiscover to { viewModel.getDiscoveryFragment }) if (viewModel.isDownloadsFragmentEnabled) { - add(R.id.fragmentDownloads to DownloadsFragment()) + add(R.id.fragmentDownloads to { DownloadsFragment() }) } - add(R.id.fragmentProfile to ProfileFragment()) + add(R.id.fragmentProfile to { ProfileFragment() }) } } - private fun addMenuItems(menu: Menu, tabList: List>) { + private fun addMenuItems(menu: Menu, tabList: List Fragment>>) { val tabTitles = mapOf( R.id.fragmentLearn to resources.getString(R.string.app_navigation_learn), R.id.fragmentDiscover to resources.getString(R.string.app_navigation_discovery), @@ -128,7 +128,7 @@ class MainFragment : Fragment(R.layout.fragment_main) { } } - private fun setupBottomNavListener(tabList: List>) { + private fun setupBottomNavListener(tabList: List Fragment>>) { val menuIdToIndex = tabList.mapIndexed { index, pair -> pair.first to index }.toMap() binding.bottomNavView.setOnItemSelectedListener { menuItem -> @@ -173,21 +173,21 @@ class MainFragment : Fragment(R.layout.fragment_main) { } else { R.id.fragmentLearn } + HomeTab.PROFILE.name -> R.id.fragmentProfile else -> R.id.fragmentLearn } } - private fun initViewPager(tabList: List>) { + private fun initViewPager(tabList: List Fragment>>) { binding.viewPager.orientation = ViewPager2.ORIENTATION_HORIZONTAL binding.viewPager.offscreenPageLimit = tabList.size - - adapter = NavigationFragmentAdapter(this).apply { - tabList.forEach { (_, fragment) -> - addFragment(fragment) + binding.viewPager.adapter = NavigationFragmentAdapter(this).apply { + tabList.forEach { (_, fragmentFactory) -> + // Use fragment factory to prevent memory leaks + addFragment { fragmentFactory() } } } - binding.viewPager.adapter = adapter binding.viewPager.isUserInputEnabled = false } diff --git a/core/src/main/java/org/openedx/core/adapter/NavigationFragmentAdapter.kt b/core/src/main/java/org/openedx/core/adapter/NavigationFragmentAdapter.kt index 708b43829..f3d210449 100644 --- a/core/src/main/java/org/openedx/core/adapter/NavigationFragmentAdapter.kt +++ b/core/src/main/java/org/openedx/core/adapter/NavigationFragmentAdapter.kt @@ -5,13 +5,13 @@ import androidx.viewpager2.adapter.FragmentStateAdapter class NavigationFragmentAdapter(fragment: Fragment) : FragmentStateAdapter(fragment) { - private val fragments = ArrayList() + private val fragmentFactories = ArrayList<() -> Fragment>() - override fun getItemCount(): Int = fragments.size + override fun getItemCount(): Int = fragmentFactories.size - override fun createFragment(position: Int): Fragment = fragments[position] + override fun createFragment(position: Int): Fragment = fragmentFactories[position].invoke() - fun addFragment(fragment: Fragment) { - fragments.add(fragment) + fun addFragment(fragmentFactory: () -> Fragment) { + fragmentFactories.add(fragmentFactory) } } diff --git a/dashboard/src/main/java/org/openedx/learn/presentation/LearnFragment.kt b/dashboard/src/main/java/org/openedx/learn/presentation/LearnFragment.kt index dd5c0eb34..b7fe74fd0 100644 --- a/dashboard/src/main/java/org/openedx/learn/presentation/LearnFragment.kt +++ b/dashboard/src/main/java/org/openedx/learn/presentation/LearnFragment.kt @@ -91,8 +91,8 @@ class LearnFragment : Fragment(R.layout.fragment_learn) { binding.viewPager.offscreenPageLimit = 2 adapter = NavigationFragmentAdapter(this).apply { - addFragment(viewModel.getDashboardFragment) - addFragment(viewModel.getProgramFragment) + addFragment { viewModel.getDashboardFragment } + addFragment { viewModel.getProgramFragment } } binding.viewPager.adapter = adapter binding.viewPager.setUserInputEnabled(false) From e59df5da9a0aa380e511559f711e9e002788ceb0 Mon Sep 17 00:00:00 2001 From: PavloNetrebchuk <141041606+PavloNetrebchuk@users.noreply.github.com> Date: Wed, 6 Aug 2025 14:23:23 +0300 Subject: [PATCH 16/24] Refactor: resourses (#455) * refactor: Removed useless resources, optimized imports * refactor: Rename drawables --- .../push/OpenEdXFirebaseMessagingService.kt | 3 +- ...{splash_inset.xml => app_splash_inset.xml} | 0 .../mipmap-hdpi/ic_launcher_foreground.png | Bin 4281 -> 0 bytes app/src/main/res/values/dimens.xml | 4 +- .../res/values/ic_launcher_background.xml | 4 -- app/src/main/res/values/splash.xml | 2 +- app/src/main/res/values/strings.xml | 5 -- .../restore/RestorePasswordFragment.kt | 2 +- .../signup/compose/SocialSignedView.kt | 2 +- .../presentation/sso/BrowserAuthHelper.kt | 2 +- .../auth/presentation/ui/SocialAuthView.kt | 6 +- ...auth_facebook.xml => auth_ic_facebook.xml} | 0 ...{ic_auth_google.xml => auth_ic_google.xml} | 0 .../main/res/drawable/auth_ic_microsoft.xml | 18 ++++++ .../main/res/drawable/ic_auth_microsoft.xml | 18 ------ auth/src/main/res/values/strings.xml | 1 - .../java/org/openedx/core/AppDataConstants.kt | 2 +- .../java/org/openedx/core/data/model/Media.kt | 2 +- .../dialog/downloaddialog/DownloadView.kt | 2 +- .../SelectBottomDialogFragment.kt | 3 +- ...pter_icon.xml => core_ic_chapter_icon.xml} | 0 core/src/main/res/drawable/core_ic_check.xml | 18 +++--- core/src/main/res/drawable/core_ic_edit.xml | 31 ----------- .../res/drawable/core_ic_screen_rotation.xml | 15 ----- core/src/main/res/drawable/ic_core_check.xml | 9 --- core/src/main/res/font/font.xml | 48 ---------------- core/src/main/res/values/strings.xml | 14 ----- core/src/main/res/values/themes.xml | 5 +- .../outline/CourseOutlineScreen.kt | 10 ++-- .../section/CourseSectionFragment.kt | 2 +- .../course/presentation/ui/CourseUI.kt | 12 ++-- .../course/presentation/ui/CourseVideosUI.kt | 28 +++++----- .../unit/video/VideoFullScreenFragment.kt | 4 +- .../unit/video/VideoUnitFragment.kt | 2 +- .../main/res/anim/course_slide_in_down.xml | 7 --- .../src/main/res/anim/course_slide_in_up.xml | 8 --- .../main/res/anim/course_slide_out_down.xml | 8 --- .../src/main/res/anim/course_slide_out_up.xml | 7 --- ...rrow_down.xml => course_ic_arrow_down.xml} | 2 +- .../src/main/res/drawable/course_ic_block.xml | 51 +++++++++++++++++ .../res/drawable/course_ic_calenday_sync.xml | 12 ---- .../res/drawable/course_ic_certificate.xml | 9 +++ .../res/drawable/course_ic_discussion.xml | 16 ++++++ ...c_course_gated.xml => course_ic_gated.xml} | 4 +- .../src/main/res/drawable/course_ic_pen.xml | 30 ++++++++++ .../drawable/course_ic_screen_rotation.xml | 36 ------------ .../src/main/res/drawable/course_ic_video.xml | 23 ++++++++ .../main/res/drawable/ic_calendar_month.xml | 5 -- .../src/main/res/drawable/ic_course_block.xml | 52 ------------------ .../res/drawable/ic_course_certificate.xml | 9 --- .../res/drawable/ic_course_discussion.xml | 17 ------ .../ic_course_navigation_discussions.xml | 17 ------ .../drawable/ic_course_navigation_more.xml | 45 --------------- .../drawable/ic_course_navigation_outline.xml | 45 --------------- .../drawable/ic_course_navigation_video.xml | 24 -------- .../src/main/res/drawable/ic_course_pen.xml | 31 ----------- .../src/main/res/drawable/ic_course_video.xml | 24 -------- course/src/main/res/drawable/ic_lock.xml | 9 --- course/src/main/res/drawable/rounded_top.xml | 8 --- course/src/main/res/values/strings.xml | 13 ----- course/src/main/res/values/values.xml | 7 +-- .../presentation/DashboardGalleryView.kt | 2 +- dashboard/src/main/res/values/strings.xml | 1 - .../presentation/info/CourseInfoFragment.kt | 3 +- .../presentation/program/ProgramFragment.kt | 6 +- .../discovery/presentation/ui/DiscoveryUI.kt | 14 ++--- .../discussion/data/api/DiscussionApi.kt | 8 +-- .../threads/DiscussionAddThreadFragment.kt | 2 +- discussion/src/main/res/values/strings.xml | 5 -- profile/src/main/res/values/strings.xml | 6 -- 70 files changed, 217 insertions(+), 623 deletions(-) rename app/src/main/res/drawable/{splash_inset.xml => app_splash_inset.xml} (100%) delete mode 100644 app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png delete mode 100644 app/src/main/res/values/ic_launcher_background.xml rename auth/src/main/res/drawable/{ic_auth_facebook.xml => auth_ic_facebook.xml} (100%) rename auth/src/main/res/drawable/{ic_auth_google.xml => auth_ic_google.xml} (100%) create mode 100644 auth/src/main/res/drawable/auth_ic_microsoft.xml delete mode 100644 auth/src/main/res/drawable/ic_auth_microsoft.xml rename core/src/main/res/drawable/{ic_core_chapter_icon.xml => core_ic_chapter_icon.xml} (100%) delete mode 100644 core/src/main/res/drawable/core_ic_edit.xml delete mode 100644 core/src/main/res/drawable/core_ic_screen_rotation.xml delete mode 100644 core/src/main/res/drawable/ic_core_check.xml delete mode 100644 core/src/main/res/font/font.xml delete mode 100644 course/src/main/res/anim/course_slide_in_down.xml delete mode 100644 course/src/main/res/anim/course_slide_in_up.xml delete mode 100644 course/src/main/res/anim/course_slide_out_down.xml delete mode 100644 course/src/main/res/anim/course_slide_out_up.xml rename course/src/main/res/drawable/{ic_course_arrow_down.xml => course_ic_arrow_down.xml} (86%) create mode 100644 course/src/main/res/drawable/course_ic_block.xml delete mode 100644 course/src/main/res/drawable/course_ic_calenday_sync.xml create mode 100644 course/src/main/res/drawable/course_ic_certificate.xml create mode 100644 course/src/main/res/drawable/course_ic_discussion.xml rename course/src/main/res/drawable/{ic_course_gated.xml => course_ic_gated.xml} (89%) create mode 100644 course/src/main/res/drawable/course_ic_pen.xml delete mode 100644 course/src/main/res/drawable/course_ic_screen_rotation.xml create mode 100644 course/src/main/res/drawable/course_ic_video.xml delete mode 100644 course/src/main/res/drawable/ic_calendar_month.xml delete mode 100644 course/src/main/res/drawable/ic_course_block.xml delete mode 100644 course/src/main/res/drawable/ic_course_certificate.xml delete mode 100644 course/src/main/res/drawable/ic_course_discussion.xml delete mode 100644 course/src/main/res/drawable/ic_course_navigation_discussions.xml delete mode 100644 course/src/main/res/drawable/ic_course_navigation_more.xml delete mode 100644 course/src/main/res/drawable/ic_course_navigation_outline.xml delete mode 100644 course/src/main/res/drawable/ic_course_navigation_video.xml delete mode 100644 course/src/main/res/drawable/ic_course_pen.xml delete mode 100644 course/src/main/res/drawable/ic_course_video.xml delete mode 100644 course/src/main/res/drawable/ic_lock.xml delete mode 100644 course/src/main/res/drawable/rounded_top.xml diff --git a/app/src/main/java/org/openedx/app/system/push/OpenEdXFirebaseMessagingService.kt b/app/src/main/java/org/openedx/app/system/push/OpenEdXFirebaseMessagingService.kt index 2d5b47410..52caf4de7 100644 --- a/app/src/main/java/org/openedx/app/system/push/OpenEdXFirebaseMessagingService.kt +++ b/app/src/main/java/org/openedx/app/system/push/OpenEdXFirebaseMessagingService.kt @@ -3,7 +3,6 @@ package org.openedx.app.system.push import android.app.NotificationChannel import android.app.NotificationManager import android.app.PendingIntent -import android.content.Context import android.content.Intent import android.media.RingtoneManager import android.os.Build @@ -75,7 +74,7 @@ class OpenEdXFirebaseMessagingService : FirebaseMessagingService() { .setContentIntent(pendingIntent) val notificationManager = - getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager + getSystemService(NOTIFICATION_SERVICE) as NotificationManager // Since android Oreo notification channel is needed. if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { diff --git a/app/src/main/res/drawable/splash_inset.xml b/app/src/main/res/drawable/app_splash_inset.xml similarity index 100% rename from app/src/main/res/drawable/splash_inset.xml rename to app/src/main/res/drawable/app_splash_inset.xml diff --git a/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png b/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png deleted file mode 100644 index d34811e00e9ff52da255a2ed6e37071e9ab16a2c..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 4281 zcmc&&_di>G8&;~VQ6t2xs(5%pC}Kn@YSpS;f+AW;i`ttKeS})E_g14u)UG~8?JBWq z)UFscq7y9i*WytTmCXJ6VX0DBQ&_rGM4bQO(OFsqcl^=Whh|JlMd5BrRRAu}s1vaEU^*htrTsyiXr&k14QnCk(uABmjl_Xsinr5SF^Q;ZMWC|Mz}!HpPt=^>CLQh?&LdTu;Q z^v{P&l+#5Ah|S5g{mbIdmsVtF*nXDXNX;-8C9k$PgKWLCX%ok1$$=R=uiD`*}*{MWK}? zy;;2{y%`QlKp&)oIDXD2BKqsr8gL!Vq2cVX0bFT=`p1u{OO}@6GM?*MduXQ!nHSq& zs8;d)3noY>Z#ejRQ28QPx;P{qUq;-JeUf0}o{iN?zl&eOt=BbSpHtP)W2F~;Q25%a>5P=?+!=K}cY!U^`#GPmIXNm3 z`~x*kyPqFhZo(e~(Q;n-SiksOkuPjN0rbs{b*cav=`vg!(WcSW^3AVgSlep){En!{~4v zyo7&%e2$pUY=D>KuZBMP?S9yR=rX`7?#EunckITiIvJyD+*eZ+h1O<{G#Ee6?rt!I zTA1(cEoQEc>>lk9fHVHhsYDW1m3FOj+n50m7e=#P{R#!SS{Es}DIBD9?@`GT!~MM; z@BH|>3w52x1C?tn9wwgKRIB{h4Ne+ug{KGGtCv7On>BN5ELK`y2T(U&QC|q0`xH5+ z5%7+`7SDn?Zza`Ir8)rglbr0$TU?cH--lm9{+0(s z4P4_z@bPDQ&Sj2^7bc}(mGuY)K@)`#?LTBrHHOwz`j%tP*iGf*=yJ60=0hRsQ+o}C zqx?RkC2-+^2Cr`p3%^ZJvZX$+Ow(S^4U7Gu>FN2CMy3{8N zX;6;r1mIWuF}&Kz>J!)=sj#3K1XJ8wetx8>RoCdrvFqkd^+Q%T`I+D7v^{aLZC<1n zymTshJ2n{lhPdryM?HUkuC@NAIImX9gS@=FrF$Kw^}BBcxe&vaW4cuqWiQPtE4z1^ z);`v{6+c^(-I^Rgxh{0G9=L&wfe=)&DJysMc>@c_^*Av?xLQ?mA@GviLx0jDKNqrt z0T%uDXqh_j)8#5+yL`8NkbQG&zMlYnSC=+_MNRq2-eUMjr^wn?`IKU19OJxs`;b<; zbxWJt!zUd*9!I9fvWEucdhH$X^Ge%eeex*YegB*>US+3JXr102cIeZSw9_cy>Gn|4 zq`5rj6RGFghaIaWv)1lM#c8Bco%mjgsOaR4X+_s1oh6)5J>pqN2aZoYh}0d1u1O;` zsj?q5r0-X=?=#<8d&ZQyxgo@acvA%y5`y(bn#9G&8#rc@HpupjOau{99lF1?B0I1T z=NE5MpDEw|0iU&Mp;!FC)`!YWK8C5l*}!}zfvo%QCENuG6s5A@Z1T0b)rlSX&pFS( zSC^$rd+`4h>AUTfP*IDz>Px#I6UCtq+)Hy6LvvMD)D4!^{Seu)bL!Pu7y8*9#97X> zA|n>YTT)}NS96u@%p`?(hxZ~d5Y!6u8*F{41k&o}mjbiFg?2iR#{~OgRq!q7(-qJ0 z2Hzkr`@Uq2#M}MEK?Ioewn&^ra$=&I ze|wNI5Iol;-;Db9sNnurxlo(o!n|un@mL=cOp5`z126ooiQQCY2kfitMjOLWLn{Y< zrzCVi&;-e=U1-p{R)%}UmnKg`^X8MrgPV?dpt5Z zVS+KtyBMReOfx;MnaNSLjg`f6PH$;;#RqUb`^1z=pcUETI2=Rn0=(C~8|M0A;p z_ZvaIeMnUrK(}Eyiw0bm(Y-S}X~LwA+G#F;Kwt3C~5T z07V+@sq9k<;3PN*D3XWLc+^s*MQlg&`^gy;I|k>SlhUCYQi%2As2?iQm!2g{P*t|I zT%joo@KN}B>ey$4TMkzO)Z`$HnCugk70QH7`ym5%STb7(+A>Bm3NrfS_5w`cnmR>} zg)|hb=kWy4XAN&897oUuRNNs>l*(zQiG8g9QHMUdBIUi6zvA>R2~zG<28|3a4ip32(&jqsz{C`d)_oIdCf9JRN3q81qF zkg_9?ac48nS=3h8=<~n;+1CDdhond`;utQmhSTPQcEeF|SeHSpW1-dsta6Yc^yk}W zT?o(q`ZtHf{7gP)Wh%cR*H01YRjx#6iiGbgkiowX-(T=z1PI55r-;27b6H3l3EfK; zN1^6JmCF6)T-}bmKbnmmU=7@NKaih>PjJwmEes`CA2V;qDLRvPHu26o-N#3H+sBCF z-Q$BU{3`NrED>(*^<~s`#{fxnzy9L&46mA_YzIpO1wy7Jw}3;R;k|HTe>fiv8;BvJ?)4FpkwXajMK?uAf%#Ft|pwB{SK)YKe8Tp;bx! zT$IK3I_)z%H1Nw*L8oy&*cqG8I75dvVey;hOR4{0+_uQ%)v`4M0Y_B*MFTx ztKP{aGAsNLaiRaVUljj9@y=;bpT(KdOX!r`K3cg#r+#-pBMuRz{aymMd(}G$+a21M zxJ8`ETsWCSEQkU=WK_)TI`LGU%|6NChpZ})mOFd`0%9lhS=Fl(V}74Pc(P-;yjrcs zD(2gDx7FrtLPeF8*?l1wZ5Ew{wQP^L9u=8cAnY-97mx3iSmRLID+2U)cIaAK&EsLj z!@Z_wx0oEpS8!wuurp2bvKh3CRIL*O#Vn>tHZhuDQ{mGM*)QycHlgNDA}6s{$_svv z0Q+_qz=&6M+Ev&2v)R!J=X4U8v*Shd=#Oq+Sdq38x8+|TUd;LHt2WXXVu{ISzF+y@($5c%%5>4?#Tn7nCBK z!mzKE#CS|o(=MDLHn0du&HJQ6<6JfmcojOAc(ZB#+zq#YW(U`*UfjGtiF6Gui2d_n z diff --git a/app/src/main/res/values/dimens.xml b/app/src/main/res/values/dimens.xml index 125df8711..73862c416 100644 --- a/app/src/main/res/values/dimens.xml +++ b/app/src/main/res/values/dimens.xml @@ -1,3 +1 @@ - - 16dp - \ No newline at end of file + \ No newline at end of file diff --git a/app/src/main/res/values/ic_launcher_background.xml b/app/src/main/res/values/ic_launcher_background.xml deleted file mode 100644 index 7e567b52f..000000000 --- a/app/src/main/res/values/ic_launcher_background.xml +++ /dev/null @@ -1,4 +0,0 @@ - - - #72BB25 - \ No newline at end of file diff --git a/app/src/main/res/values/splash.xml b/app/src/main/res/values/splash.xml index e206c4bc1..7865c7d37 100644 --- a/app/src/main/res/values/splash.xml +++ b/app/src/main/res/values/splash.xml @@ -7,7 +7,7 @@ - @drawable/splash_inset + @drawable/app_splash_inset 300 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index bfffb806e..801ce0c80 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1,11 +1,6 @@ - Settings - Next - Previous - Discover Learn - Programs Profile Downloads diff --git a/auth/src/main/java/org/openedx/auth/presentation/restore/RestorePasswordFragment.kt b/auth/src/main/java/org/openedx/auth/presentation/restore/RestorePasswordFragment.kt index 332aa6faa..81d216c39 100644 --- a/auth/src/main/java/org/openedx/auth/presentation/restore/RestorePasswordFragment.kt +++ b/auth/src/main/java/org/openedx/auth/presentation/restore/RestorePasswordFragment.kt @@ -186,7 +186,7 @@ private fun RestorePasswordScreen( modifier = Modifier .fillMaxWidth() .height(200.dp), - painter = painterResource(id = org.openedx.core.R.drawable.core_top_header), + painter = painterResource(id = R.drawable.core_top_header), contentScale = ContentScale.FillBounds, contentDescription = null ) diff --git a/auth/src/main/java/org/openedx/auth/presentation/signup/compose/SocialSignedView.kt b/auth/src/main/java/org/openedx/auth/presentation/signup/compose/SocialSignedView.kt index b2dee1919..2045297a5 100644 --- a/auth/src/main/java/org/openedx/auth/presentation/signup/compose/SocialSignedView.kt +++ b/auth/src/main/java/org/openedx/auth/presentation/signup/compose/SocialSignedView.kt @@ -40,7 +40,7 @@ internal fun SocialSignedView(authType: AuthType) { modifier = Modifier .padding(end = 8.dp) .size(20.dp), - painter = painterResource(id = coreR.drawable.ic_core_check), + painter = painterResource(id = coreR.drawable.core_ic_check), tint = MaterialTheme.appColors.successBackground, contentDescription = "" ) diff --git a/auth/src/main/java/org/openedx/auth/presentation/sso/BrowserAuthHelper.kt b/auth/src/main/java/org/openedx/auth/presentation/sso/BrowserAuthHelper.kt index 1022da676..cd3233b39 100644 --- a/auth/src/main/java/org/openedx/auth/presentation/sso/BrowserAuthHelper.kt +++ b/auth/src/main/java/org/openedx/auth/presentation/sso/BrowserAuthHelper.kt @@ -25,7 +25,7 @@ class BrowserAuthHelper(private val config: Config) { .appendQueryParameter("response_type", ApiConstants.BrowserLogin.RESPONSE_TYPE).build() val intent = CustomTabsIntent.Builder().setUrlBarHidingEnabled(true).setShowTitle(true).build() - intent.intent.setFlags(FLAG_ACTIVITY_NEW_TASK) + intent.intent.flags = FLAG_ACTIVITY_NEW_TASK intent.launchUrl(activityContext, uri) } 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 12b707033..e4962d072 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 @@ -54,7 +54,7 @@ internal fun SocialAuthView( ) { Row(verticalAlignment = Alignment.CenterVertically) { Icon( - painter = painterResource(id = R.drawable.ic_auth_google), + painter = painterResource(id = R.drawable.auth_ic_google), contentDescription = null, tint = Color.Unspecified, ) @@ -86,7 +86,7 @@ internal fun SocialAuthView( ) { Row(verticalAlignment = Alignment.CenterVertically) { Icon( - painter = painterResource(id = R.drawable.ic_auth_facebook), + painter = painterResource(id = R.drawable.auth_ic_facebook), contentDescription = null, tint = MaterialTheme.appColors.primaryButtonText, ) @@ -118,7 +118,7 @@ internal fun SocialAuthView( ) { Row(verticalAlignment = Alignment.CenterVertically) { Icon( - painter = painterResource(id = R.drawable.ic_auth_microsoft), + painter = painterResource(id = R.drawable.auth_ic_microsoft), contentDescription = null, tint = Color.Unspecified, ) diff --git a/auth/src/main/res/drawable/ic_auth_facebook.xml b/auth/src/main/res/drawable/auth_ic_facebook.xml similarity index 100% rename from auth/src/main/res/drawable/ic_auth_facebook.xml rename to auth/src/main/res/drawable/auth_ic_facebook.xml diff --git a/auth/src/main/res/drawable/ic_auth_google.xml b/auth/src/main/res/drawable/auth_ic_google.xml similarity index 100% rename from auth/src/main/res/drawable/ic_auth_google.xml rename to auth/src/main/res/drawable/auth_ic_google.xml diff --git a/auth/src/main/res/drawable/auth_ic_microsoft.xml b/auth/src/main/res/drawable/auth_ic_microsoft.xml new file mode 100644 index 000000000..30170272a --- /dev/null +++ b/auth/src/main/res/drawable/auth_ic_microsoft.xml @@ -0,0 +1,18 @@ + + + + + + diff --git a/auth/src/main/res/drawable/ic_auth_microsoft.xml b/auth/src/main/res/drawable/ic_auth_microsoft.xml deleted file mode 100644 index ce31faab7..000000000 --- a/auth/src/main/res/drawable/ic_auth_microsoft.xml +++ /dev/null @@ -1,18 +0,0 @@ - - - - - - diff --git a/auth/src/main/res/values/strings.xml b/auth/src/main/res/values/strings.xml index 49a8fb68e..77401c27f 100644 --- a/auth/src/main/res/values/strings.xml +++ b/auth/src/main/res/values/strings.xml @@ -4,7 +4,6 @@ What do you want to learn? Search our 3000+ courses Explore all courses - Sign up Forgot password? Email Invalid email diff --git a/core/src/main/java/org/openedx/core/AppDataConstants.kt b/core/src/main/java/org/openedx/core/AppDataConstants.kt index eb2580e99..a9f22e573 100644 --- a/core/src/main/java/org/openedx/core/AppDataConstants.kt +++ b/core/src/main/java/org/openedx/core/AppDataConstants.kt @@ -1,6 +1,6 @@ package org.openedx.core -import java.util.* +import java.util.Locale object AppDataConstants { const val USER_MIN_YEAR = 13 diff --git a/core/src/main/java/org/openedx/core/data/model/Media.kt b/core/src/main/java/org/openedx/core/data/model/Media.kt index 7b4998175..96ffafb4c 100644 --- a/core/src/main/java/org/openedx/core/data/model/Media.kt +++ b/core/src/main/java/org/openedx/core/data/model/Media.kt @@ -14,7 +14,7 @@ data class Media( val image: Image?, ) { - fun mapToDomain(): org.openedx.core.domain.model.Media { + fun mapToDomain(): Media { return Media( bannerImage = bannerImage?.mapToDomain(), courseImage = courseImage?.mapToDomain(), diff --git a/core/src/main/java/org/openedx/core/presentation/dialog/downloaddialog/DownloadView.kt b/core/src/main/java/org/openedx/core/presentation/dialog/downloaddialog/DownloadView.kt index 4469f0b8e..58a5f9d22 100644 --- a/core/src/main/java/org/openedx/core/presentation/dialog/downloaddialog/DownloadView.kt +++ b/core/src/main/java/org/openedx/core/presentation/dialog/downloaddialog/DownloadView.kt @@ -27,7 +27,7 @@ fun DownloadDialogItem( val icon = if (downloadDialogItem.icon != null) { rememberVectorPainter(downloadDialogItem.icon) } else { - painterResource(id = R.drawable.ic_core_chapter_icon) + painterResource(id = R.drawable.core_ic_chapter_icon) } Row( modifier = modifier.padding(vertical = 6.dp), diff --git a/core/src/main/java/org/openedx/core/presentation/dialog/selectorbottomsheet/SelectBottomDialogFragment.kt b/core/src/main/java/org/openedx/core/presentation/dialog/selectorbottomsheet/SelectBottomDialogFragment.kt index 6b7f5ffcf..3890aa360 100644 --- a/core/src/main/java/org/openedx/core/presentation/dialog/selectorbottomsheet/SelectBottomDialogFragment.kt +++ b/core/src/main/java/org/openedx/core/presentation/dialog/selectorbottomsheet/SelectBottomDialogFragment.kt @@ -25,7 +25,6 @@ import androidx.compose.ui.platform.ComposeView import androidx.compose.ui.platform.ViewCompositionStrategy import androidx.compose.ui.text.input.TextFieldValue import androidx.compose.ui.unit.dp -import androidx.fragment.app.DialogFragment import com.google.android.material.bottomsheet.BottomSheetBehavior import com.google.android.material.bottomsheet.BottomSheetDialog import com.google.android.material.bottomsheet.BottomSheetDialogFragment @@ -47,7 +46,7 @@ class SelectBottomDialogFragment : BottomSheetDialogFragment() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) viewModel.values = requireArguments().parcelableArrayList(ARG_LIST_VALUES)!! - setStyle(DialogFragment.STYLE_NORMAL, R.style.BottomSheetDialog) + setStyle(STYLE_NORMAL, R.style.BottomSheetDialog) } override fun onCreateView( diff --git a/core/src/main/res/drawable/ic_core_chapter_icon.xml b/core/src/main/res/drawable/core_ic_chapter_icon.xml similarity index 100% rename from core/src/main/res/drawable/ic_core_chapter_icon.xml rename to core/src/main/res/drawable/core_ic_chapter_icon.xml diff --git a/core/src/main/res/drawable/core_ic_check.xml b/core/src/main/res/drawable/core_ic_check.xml index 81badcbcd..381b4712a 100644 --- a/core/src/main/res/drawable/core_ic_check.xml +++ b/core/src/main/res/drawable/core_ic_check.xml @@ -1,13 +1,9 @@ - + android:width="16dp" + android:height="16dp" + android:viewportWidth="16" + android:viewportHeight="16"> + diff --git a/core/src/main/res/drawable/core_ic_edit.xml b/core/src/main/res/drawable/core_ic_edit.xml deleted file mode 100644 index 62f035a78..000000000 --- a/core/src/main/res/drawable/core_ic_edit.xml +++ /dev/null @@ -1,31 +0,0 @@ - - - - - - - - diff --git a/core/src/main/res/drawable/core_ic_screen_rotation.xml b/core/src/main/res/drawable/core_ic_screen_rotation.xml deleted file mode 100644 index 0d842b791..000000000 --- a/core/src/main/res/drawable/core_ic_screen_rotation.xml +++ /dev/null @@ -1,15 +0,0 @@ - - - - - diff --git a/core/src/main/res/drawable/ic_core_check.xml b/core/src/main/res/drawable/ic_core_check.xml deleted file mode 100644 index 10551dea9..000000000 --- a/core/src/main/res/drawable/ic_core_check.xml +++ /dev/null @@ -1,9 +0,0 @@ - - - diff --git a/core/src/main/res/font/font.xml b/core/src/main/res/font/font.xml deleted file mode 100644 index 4cdad3af5..000000000 --- a/core/src/main/res/font/font.xml +++ /dev/null @@ -1,48 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - diff --git a/core/src/main/res/values/strings.xml b/core/src/main/res/values/strings.xml index 99df5b3d4..5dd32864c 100644 --- a/core/src/main/res/values/strings.xml +++ b/core/src/main/res/values/strings.xml @@ -2,7 +2,6 @@ @string/platform_name - Results Invalid credentials Slow or no internet connection Something went wrong @@ -57,7 +56,6 @@ Settings App Update Required This version of the OpenEdX app is out-of-date. To continue learning and get the latest features and fixes, please upgrade to the latest version. - Why do I need to update? Version: %1$s Up-to-date Tap to update to version %1$s @@ -130,12 +128,10 @@ 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. @@ -157,18 +153,8 @@ 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 - More - Dates No course content is currently available. There are currently no videos for this course. Course dates are currently not available. diff --git a/core/src/main/res/values/themes.xml b/core/src/main/res/values/themes.xml index e6859e022..e43010475 100644 --- a/core/src/main/res/values/themes.xml +++ b/core/src/main/res/values/themes.xml @@ -1,4 +1,4 @@ - + - - \ No newline at end of file + \ No newline at end of file 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 aae42e985..c7108405a 100644 --- a/dashboard/src/main/java/org/openedx/courses/presentation/DashboardGalleryView.kt +++ b/dashboard/src/main/java/org/openedx/courses/presentation/DashboardGalleryView.kt @@ -678,7 +678,7 @@ private fun PrimaryCourseButtons( navigateToDates(primaryCourse) } }, - painter = painterResource(id = CoreR.drawable.ic_core_chapter_icon), + painter = painterResource(id = CoreR.drawable.core_ic_chapter_icon), title = title, info = stringResource( R.string.dashboard_assignment_due, diff --git a/dashboard/src/main/res/values/strings.xml b/dashboard/src/main/res/values/strings.xml index 01979f21d..74909ac48 100644 --- a/dashboard/src/main/res/values/strings.xml +++ b/dashboard/src/main/res/values/strings.xml @@ -4,7 +4,6 @@ You are not enrolled in any courses yet. Learn Programs - Course %1$s Start Course Resume Course View All Courses (%1$d) diff --git a/discovery/src/main/java/org/openedx/discovery/presentation/info/CourseInfoFragment.kt b/discovery/src/main/java/org/openedx/discovery/presentation/info/CourseInfoFragment.kt index 21098a55f..7c397f206 100644 --- a/discovery/src/main/java/org/openedx/discovery/presentation/info/CourseInfoFragment.kt +++ b/discovery/src/main/java/org/openedx/discovery/presentation/info/CourseInfoFragment.kt @@ -57,7 +57,6 @@ import org.openedx.core.ui.theme.appColors import org.openedx.discovery.R import org.openedx.discovery.presentation.DiscoveryAnalyticsScreen import org.openedx.discovery.presentation.catalog.CatalogWebViewScreen -import org.openedx.discovery.presentation.catalog.WebViewLink import org.openedx.foundation.presentation.UIMessage import org.openedx.foundation.presentation.WindowSize import org.openedx.foundation.presentation.WindowType @@ -251,7 +250,7 @@ private fun CourseInfoScreen( onRegisterClick: () -> Unit, onSignInClick: () -> Unit, onBackClick: () -> Unit, - onUriClick: (String, WebViewLink.Authority) -> Unit, + onUriClick: (String, linkAuthority) -> Unit, ) { val scaffoldState = rememberScaffoldState() val configuration = LocalConfiguration.current 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 308cdd52d..1c78faf23 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 @@ -60,7 +60,6 @@ import org.openedx.core.ui.theme.appColors import org.openedx.discovery.R import org.openedx.discovery.presentation.DiscoveryAnalyticsScreen import org.openedx.discovery.presentation.catalog.CatalogWebViewScreen -import org.openedx.discovery.presentation.catalog.WebViewLink import org.openedx.foundation.extension.takeIfNotEmpty import org.openedx.foundation.extension.toastMessage import org.openedx.foundation.presentation.WindowSize @@ -170,8 +169,7 @@ class ProgramFragment : Fragment() { } linkAuthority.PROGRAM_INFO, - linkAuthority.COURSE_INFO, - -> { + linkAuthority.COURSE_INFO -> { viewModel.onViewCourseClick( fragmentManager = requireActivity().supportFragmentManager, courseId = param, @@ -251,7 +249,7 @@ private fun ProgramInfoScreen( onWebViewUIAction: (WebViewUIAction) -> Unit, onSettingsClick: () -> Unit, onBackClick: () -> Unit, - onUriClick: (String, WebViewLink.Authority) -> Unit, + onUriClick: (String, linkAuthority) -> Unit, ) { val scaffoldState = rememberScaffoldState() val configuration = LocalConfiguration.current diff --git a/discovery/src/main/java/org/openedx/discovery/presentation/ui/DiscoveryUI.kt b/discovery/src/main/java/org/openedx/discovery/presentation/ui/DiscoveryUI.kt index e4c7687a6..eeb497f56 100644 --- a/discovery/src/main/java/org/openedx/discovery/presentation/ui/DiscoveryUI.kt +++ b/discovery/src/main/java/org/openedx/discovery/presentation/ui/DiscoveryUI.kt @@ -49,7 +49,7 @@ import org.openedx.foundation.extension.toImageLink import org.openedx.foundation.presentation.WindowSize import org.openedx.foundation.presentation.rememberWindowSize import org.openedx.foundation.presentation.windowSizeValue -import org.openedx.core.R as CoreR +import org.openedx.core.R as сoreR @Composable fun ImageHeader( @@ -70,11 +70,11 @@ fun ImageHeader( AsyncImage( model = ImageRequest.Builder(LocalContext.current) .data(courseImage?.toImageLink(apiHostUrl)) - .error(CoreR.drawable.core_no_image_course) - .placeholder(CoreR.drawable.core_no_image_course) + .error(сoreR.drawable.core_no_image_course) + .placeholder(сoreR.drawable.core_no_image_course) .build(), contentDescription = stringResource( - id = CoreR.string.core_accessibility_header_image_for, + id = сoreR.string.core_accessibility_header_image_for, courseName ), contentScale = contentScale, @@ -119,8 +119,8 @@ fun DiscoveryCourseItem( AsyncImage( model = ImageRequest.Builder(LocalContext.current) .data(course.media.courseImage?.uri?.toImageLink(apiHostUrl) ?: "") - .error(org.openedx.core.R.drawable.core_no_image_course) - .placeholder(org.openedx.core.R.drawable.core_no_image_course) + .error(сoreR.drawable.core_no_image_course) + .placeholder(сoreR.drawable.core_no_image_course) .build(), contentDescription = null, contentScale = ContentScale.Crop, @@ -216,7 +216,7 @@ fun WarningLabel( private fun WarningLabelPreview() { OpenEdXTheme { WarningLabel( - painter = painterResource(id = CoreR.drawable.core_ic_offline), + painter = painterResource(id = сoreR.drawable.core_ic_offline), text = stringResource(id = R.string.discovery_no_internet_label) ) } diff --git a/discussion/src/main/java/org/openedx/discussion/data/api/DiscussionApi.kt b/discussion/src/main/java/org/openedx/discussion/data/api/DiscussionApi.kt index 37f84aa3a..4f1eee74a 100644 --- a/discussion/src/main/java/org/openedx/discussion/data/api/DiscussionApi.kt +++ b/discussion/src/main/java/org/openedx/discussion/data/api/DiscussionApi.kt @@ -80,28 +80,28 @@ interface DiscussionApi { suspend fun setThreadRead( @Path("thread_id") threadId: String, @Body body: ReadBody - ): ThreadsResponse.Thread + ): Thread @Headers("Cache-Control: no-cache", "Content-type: application/merge-patch+json") @PATCH("/api/discussion/v1/threads/{thread_id}/") suspend fun setThreadVoted( @Path("thread_id") threadId: String, @Body body: VoteBody - ): ThreadsResponse.Thread + ): Thread @Headers("Cache-Control: no-cache", "Content-type: application/merge-patch+json") @PATCH("/api/discussion/v1/threads/{thread_id}/") suspend fun setThreadFlagged( @Path("thread_id") threadId: String, @Body reportBody: ReportBody - ): ThreadsResponse.Thread + ): Thread @Headers("Cache-Control: no-cache", "Content-type: application/merge-patch+json") @PATCH("/api/discussion/v1/threads/{thread_id}/") suspend fun setThreadFollowed( @Path("thread_id") threadId: String, @Body followBody: FollowBody - ): ThreadsResponse.Thread + ): Thread @Headers("Cache-Control: no-cache", "Content-type: application/merge-patch+json") @PATCH("/api/discussion/v1/comments/{comment_id}/") diff --git a/discussion/src/main/java/org/openedx/discussion/presentation/threads/DiscussionAddThreadFragment.kt b/discussion/src/main/java/org/openedx/discussion/presentation/threads/DiscussionAddThreadFragment.kt index c66838fb0..bda4e3730 100644 --- a/discussion/src/main/java/org/openedx/discussion/presentation/threads/DiscussionAddThreadFragment.kt +++ b/discussion/src/main/java/org/openedx/discussion/presentation/threads/DiscussionAddThreadFragment.kt @@ -389,7 +389,7 @@ private fun DiscussionAddThreadScreen( .fillMaxWidth() .height(150.dp), title = if (currentPage == 0) { - stringResource(id = org.openedx.discussion.R.string.discussion_discussion) + stringResource(id = discussionR.string.discussion_discussion) } else { stringResource( id = discussionR.string.discussion_question diff --git a/discussion/src/main/res/values/strings.xml b/discussion/src/main/res/values/strings.xml index a9b11d04d..02bd2bcba 100644 --- a/discussion/src/main/res/values/strings.xml +++ b/discussion/src/main/res/values/strings.xml @@ -1,10 +1,8 @@ - Discussions All Posts Unread Unanswered Posts I\'m following - Refine: Recent activity Most activity Most votes @@ -23,9 +21,6 @@ Title Follow this discussion Follow this question - Post discussion - Post question - General Search all posts Main categories Select post type diff --git a/profile/src/main/res/values/strings.xml b/profile/src/main/res/values/strings.xml index 1adf22c97..1de55c683 100644 --- a/profile/src/main/res/values/strings.xml +++ b/profile/src/main/res/values/strings.xml @@ -1,13 +1,8 @@ - Profile info - Bio: %1$s - Year of Birth: %1$s Full profile Limited profile Edit Profile - Edit - Save Delete Account You must be over 13 years old to have a profile with full access to information. Year of Birth @@ -27,7 +22,6 @@ Change profile image Select from gallery Remove photo - Settings Leave without saving? Leave Keep editing From fbbf47efe549691885967f19066ef05010a3ab8c Mon Sep 17 00:00:00 2001 From: PavloNetrebchuk <141041606+PavloNetrebchuk@users.noreply.github.com> Date: Fri, 8 Aug 2025 11:15:35 +0300 Subject: [PATCH 17/24] fix: R8 (#456) --- app/proguard-rules.pro | 2 ++ app/src/main/res/values-land/dimens.xml | 3 --- app/src/main/res/values-w1240dp/dimens.xml | 3 --- app/src/main/res/values-w600dp/dimens.xml | 3 --- app/src/main/res/values/dimens.xml | 1 - .../java/org/openedx/core/config/ExperimentalFeaturesConfig.kt | 2 +- dashboard/proguard-rules.pro | 2 ++ 7 files changed, 5 insertions(+), 11 deletions(-) delete mode 100644 app/src/main/res/values-land/dimens.xml delete mode 100644 app/src/main/res/values-w1240dp/dimens.xml delete mode 100644 app/src/main/res/values-w600dp/dimens.xml delete mode 100644 app/src/main/res/values/dimens.xml diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro index 825176c61..b1a6dc13d 100644 --- a/app/proguard-rules.pro +++ b/app/proguard-rules.pro @@ -67,3 +67,5 @@ -dontwarn org.bouncycastle.openssl.PEMKeyPair -dontwarn org.bouncycastle.openssl.PEMParser -dontwarn org.bouncycastle.openssl.jcajce.JcaPEMKeyConverter +-dontwarn com.android.billingclient.api.BillingClientStateListener +-dontwarn com.android.billingclient.api.PurchasesUpdatedListener diff --git a/app/src/main/res/values-land/dimens.xml b/app/src/main/res/values-land/dimens.xml deleted file mode 100644 index 22d7f0043..000000000 --- a/app/src/main/res/values-land/dimens.xml +++ /dev/null @@ -1,3 +0,0 @@ - - 48dp - \ No newline at end of file diff --git a/app/src/main/res/values-w1240dp/dimens.xml b/app/src/main/res/values-w1240dp/dimens.xml deleted file mode 100644 index d73f4a359..000000000 --- a/app/src/main/res/values-w1240dp/dimens.xml +++ /dev/null @@ -1,3 +0,0 @@ - - 200dp - \ No newline at end of file diff --git a/app/src/main/res/values-w600dp/dimens.xml b/app/src/main/res/values-w600dp/dimens.xml deleted file mode 100644 index 22d7f0043..000000000 --- a/app/src/main/res/values-w600dp/dimens.xml +++ /dev/null @@ -1,3 +0,0 @@ - - 48dp - \ No newline at end of file diff --git a/app/src/main/res/values/dimens.xml b/app/src/main/res/values/dimens.xml deleted file mode 100644 index 73862c416..000000000 --- a/app/src/main/res/values/dimens.xml +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/core/src/main/java/org/openedx/core/config/ExperimentalFeaturesConfig.kt b/core/src/main/java/org/openedx/core/config/ExperimentalFeaturesConfig.kt index 74624178c..03dd43150 100644 --- a/core/src/main/java/org/openedx/core/config/ExperimentalFeaturesConfig.kt +++ b/core/src/main/java/org/openedx/core/config/ExperimentalFeaturesConfig.kt @@ -4,5 +4,5 @@ import com.google.gson.annotations.SerializedName data class ExperimentalFeaturesConfig( @SerializedName("APP_LEVEL_DOWNLOADS") - val appLevelDownloadsConfig: AppLevelDownloadsConfig, + val appLevelDownloadsConfig: AppLevelDownloadsConfig = AppLevelDownloadsConfig(), ) diff --git a/dashboard/proguard-rules.pro b/dashboard/proguard-rules.pro index cdb308aa0..4d3a6c1df 100644 --- a/dashboard/proguard-rules.pro +++ b/dashboard/proguard-rules.pro @@ -5,3 +5,5 @@ -dontshrink -dontoptimize -dontobfuscate + +-dontwarn java.lang.invoke.StringConcatFactory From 6880aaa2fc40b47f9d3e6418362914c865477000 Mon Sep 17 00:00:00 2001 From: PavloNetrebchuk <141041606+PavloNetrebchuk@users.noreply.github.com> Date: Thu, 14 Aug 2025 12:41:35 +0300 Subject: [PATCH 18/24] feat: lib upgrade (#457) --- app/build.gradle | 24 +++--- .../main/java/org/openedx/app/AppActivity.kt | 11 ++- auth/build.gradle | 51 ++++++------ build.gradle | 82 ++++++++++++++----- buildSrc/build.gradle | 2 +- core/build.gradle | 30 +++---- .../java/org/openedx/core/AppDataConstants.kt | 2 +- .../org/openedx/core/utils/LocaleUtils.kt | 18 ++-- .../java/org/openedx/core/utils/TimeUtils.kt | 2 +- course/build.gradle | 23 +++--- dashboard/build.gradle | 24 +++--- discovery/build.gradle | 28 +++---- discussion/build.gradle | 21 +++-- downloads/build.gradle | 23 +++--- gradle/wrapper/gradle-wrapper.properties | 4 +- profile/build.gradle | 22 +++-- settings.gradle | 2 +- whatsnew/build.gradle | 34 ++++---- 18 files changed, 221 insertions(+), 182 deletions(-) diff --git a/app/build.gradle b/app/build.gradle index 360ca6438..651ebc5b9 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -28,12 +28,13 @@ if (firebaseEnabled) { } android { - compileSdk 34 + namespace 'org.openedx.app' + compileSdkVersion compile_sdk_version defaultConfig { applicationId appId - minSdk 24 - targetSdk 34 + minSdk min_sdk_version + targetSdk target_sdk_version versionCode 1 versionName "1.0.0" @@ -42,7 +43,6 @@ android { testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" } - namespace 'org.openedx.app' flavorDimensions += "env" productFlavors { @@ -88,11 +88,11 @@ android { } } compileOptions { - sourceCompatibility JavaVersion.VERSION_17 - targetCompatibility JavaVersion.VERSION_17 + sourceCompatibility java_version + targetCompatibility java_version } kotlinOptions { - jvmTarget = JavaVersion.VERSION_17 + jvmTarget = java_version freeCompilerArgs = List.of("-Xstring-concat=inline") } buildFeatures { @@ -129,19 +129,19 @@ dependencies { ksp "androidx.room:room-compiler:$room_version" - implementation 'androidx.core:core-splashscreen:1.0.1' + implementation "androidx.core:core-splashscreen:$core_splashscreen_version" api platform("com.google.firebase:firebase-bom:$firebase_version") api "com.google.firebase:firebase-messaging" // Braze SDK Integration - implementation "com.braze:android-sdk-ui:30.2.0" + implementation "com.braze:android-sdk-ui:$braze_sdk_version" // Plugins - implementation("com.github.openedx:openedx-app-firebase-analytics-android:1.0.1") + implementation("com.github.openedx:openedx-app-firebase-analytics-android:$openedx_firebase_analytics_version") - androidTestImplementation 'androidx.test.ext:junit:1.2.1' - androidTestImplementation 'androidx.test.espresso:espresso-core:3.6.1' + androidTestImplementation "androidx.test.ext:junit:$test_ext_version" + androidTestImplementation "androidx.test.espresso:espresso-core:$espresso_version" testImplementation "junit:junit:$junit_version" testImplementation "io.mockk:mockk:$mockk_version" testImplementation "io.mockk:mockk-android:$mockk_version" diff --git a/app/src/main/java/org/openedx/app/AppActivity.kt b/app/src/main/java/org/openedx/app/AppActivity.kt index cbb496501..11b913d50 100644 --- a/app/src/main/java/org/openedx/app/AppActivity.kt +++ b/app/src/main/java/org/openedx/app/AppActivity.kt @@ -2,7 +2,6 @@ package org.openedx.app import android.content.Intent import android.content.res.Configuration -import android.graphics.Color import android.net.Uri import android.os.Bundle import android.view.View @@ -157,10 +156,10 @@ class AppActivity : AppCompatActivity(), InsetHolder, WindowSizeHolder { window.apply { addFlags(WindowManager.LayoutParams.FLAG_DRAWS_SYSTEM_BAR_BACKGROUNDS) WindowCompat.setDecorFitsSystemWindows(this, false) - val insetsController = WindowInsetsControllerCompat(this, binding.root) insetsController.isAppearanceLightStatusBars = !isUsingNightModeResources() - statusBarColor = Color.TRANSPARENT + insetsController.systemBarsBehavior = + WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE } } @@ -214,7 +213,7 @@ class AppActivity : AppCompatActivity(), InsetHolder, WindowSizeHolder { } } - override fun onNewIntent(intent: Intent?) { + override fun onNewIntent(intent: Intent) { super.onNewIntent(intent) this.intent = intent @@ -222,13 +221,13 @@ class AppActivity : AppCompatActivity(), InsetHolder, WindowSizeHolder { addFragment(SignInFragment.newInstance(null, null, authCode = authCode)) } - val extras = intent?.extras + val extras = intent.extras if (extras?.containsKey(DeepLink.Keys.NOTIFICATION_TYPE.value) == true) { handlePushNotification(extras) } if (viewModel.isBranchEnabled) { - if (intent?.getBooleanExtra(BRANCH_FORCE_NEW_SESSION, false) == true) { + if (intent.getBooleanExtra(BRANCH_FORCE_NEW_SESSION, false)) { Branch.sessionBuilder(this) .withCallback(branchCallback) .reInit() diff --git a/auth/build.gradle b/auth/build.gradle index 6b11037a2..a360c2d62 100644 --- a/auth/build.gradle +++ b/auth/build.gradle @@ -6,17 +6,17 @@ plugins { } android { - compileSdk 34 + namespace 'org.openedx.auth' + compileSdkVersion compile_sdk_version defaultConfig { - minSdk 24 - targetSdk 34 + minSdk min_sdk_version + targetSdk target_sdk_version testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" consumerProguardFiles "consumer-rules.pro" } - namespace 'org.openedx.auth' flavorDimensions += "env" productFlavors { @@ -33,17 +33,16 @@ android { buildTypes { release { - minifyEnabled true + minifyEnabled false proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro' } } compileOptions { - sourceCompatibility JavaVersion.VERSION_17 - targetCompatibility JavaVersion.VERSION_17 + sourceCompatibility java_version + targetCompatibility java_version } kotlinOptions { - jvmTarget = JavaVersion.VERSION_17 - freeCompilerArgs = List.of("-Xstring-concat=inline") + jvmTarget = java_version } buildFeatures { viewBinding true @@ -54,22 +53,28 @@ android { dependencies { implementation project(path: ':core') - implementation 'androidx.browser:browser:1.7.0' - implementation "androidx.credentials:credentials:1.3.0" - implementation "androidx.credentials:credentials-play-services-auth:1.3.0" - implementation "com.facebook.android:facebook-login:16.2.0" - implementation "com.google.android.gms:play-services-auth:21.2.0" - implementation "com.google.android.libraries.identity.googleid:googleid:1.1.1" - implementation("com.microsoft.identity.client:msal:4.9.0") { - //Workaround for the error Failed to resolve: 'io.opentelemetry:opentelemetry-bom' for AS Iguana - exclude(group: "io.opentelemetry") + // AndroidX + implementation "androidx.browser:browser:$browser_version" + implementation "androidx.credentials:credentials:$credentials_version" + implementation "androidx.credentials:credentials-play-services-auth:$credentials_version" + + // Social Login + implementation "com.facebook.android:facebook-login:$facebook_login_version" + implementation "com.google.android.gms:play-services-auth:$play_services_auth_version" + implementation "com.google.android.libraries.identity.googleid:googleid:$googleid_version" + implementation("com.microsoft.identity.client:msal:$msal_version") { + exclude group: 'com.microsoft.identity.client', module: 'msal-browser' + exclude group: 'io.opentelemetry', module: 'opentelemetry-bom' } - implementation("io.opentelemetry:opentelemetry-api:1.18.0") - implementation("io.opentelemetry:opentelemetry-context:1.18.0") - androidTestImplementation 'androidx.test.ext:junit:1.2.1' - androidTestImplementation 'androidx.test.espresso:espresso-core:3.6.1' + + // OpenTelemetry + implementation("io.opentelemetry:opentelemetry-api:$opentelemetry_version") + implementation("io.opentelemetry:opentelemetry-context:$opentelemetry_version") + testImplementation "junit:junit:$junit_version" testImplementation "io.mockk:mockk:$mockk_version" - testImplementation "io.mockk:mockk-android:$mockk_version" testImplementation "androidx.arch.core:core-testing:$android_arch_version" + testImplementation "org.jetbrains.kotlinx:kotlinx-coroutines-test:$kotlinx_coroutines_test_version" + androidTestImplementation "androidx.test.ext:junit:$test_ext_version" + androidTestImplementation "androidx.test.espresso:espresso-core:$espresso_version" } diff --git a/build.gradle b/build.gradle index f7fb3cf91..33a8a167e 100644 --- a/build.gradle +++ b/build.gradle @@ -6,20 +6,70 @@ import java.util.regex.Pattern buildscript { ext { + // Plugin versions + android_gradle_plugin_version = '8.12.0' + google_services_version = '4.4.3' + firebase_crashlytics_version = '3.0.6' + ksp_version = '2.0.0-1.0.24' + //Depends on versions in OEXFoundation kotlin_version = '2.0.0' room_version = '2.6.1' detekt_version = '1.23.7' + + // Library versions + media3_version = "1.8.0" + youtubeplayer_version = "11.1.0" + firebase_version = "33.0.0" + jsoup_version = '1.21.1' + in_app_review = '2.0.2' + extented_spans_version = "1.4.0" + zip_version = '2.11.5' + + // Third-party library versions + branch_sdk_version = '5.20.0' + play_services_ads_identifier_version = '18.2.0' + install_referrer_version = '2.2' + snakeyaml_version = '2.4' + openedx_foundation_version = '1.0.1' + openedx_firebase_analytics_version = '1.0.1' + braze_sdk_version = '37.0.0' + + // AndroidX library versions + core_splashscreen_version = '1.0.1' + activity_compose_version = '1.10.1' + browser_version = '1.9.0' + credentials_version = '1.5.0' + + // Social login versions + facebook_login_version = '18.1.3' + play_services_auth_version = '21.4.0' + googleid_version = '1.1.1' + msal_version = '7.0.0' + + // OpenTelemetry versions + opentelemetry_version = '1.53.0' + + // Testing versions + compose_ui_tooling = '1.7.8' + mockk_version = '1.14.5' + android_arch_version = '2.2.0' + junit_version = '4.13.2' + test_ext_version = '1.3.0' + espresso_version = '3.7.0' + kotlinx_coroutines_test_version = '1.10.2' } } plugins { - id 'com.android.application' version '8.5.2' apply false - id 'com.android.library' version '8.5.2' apply false + //noinspection GradlePluginVersion + id 'com.android.application' version "$android_gradle_plugin_version" apply false + //noinspection GradlePluginVersion + id 'com.android.library' version "$android_gradle_plugin_version" apply false id 'org.jetbrains.kotlin.android' version "$kotlin_version" apply false - id 'com.google.gms.google-services' version '4.4.2' apply false - id "com.google.firebase.crashlytics" version "3.0.2" apply false - id "com.google.devtools.ksp" version "2.0.0-1.0.24" apply false + id 'com.google.gms.google-services' version "$google_services_version" apply false + id "com.google.firebase.crashlytics" version "$firebase_crashlytics_version" apply false + id "com.google.devtools.ksp" version "$ksp_version" apply false id "org.jetbrains.kotlin.plugin.compose" version "$kotlin_version" apply false id 'io.gitlab.arturbosch.detekt' version "$detekt_version" apply false } @@ -29,25 +79,13 @@ tasks.register('clean', Delete) { } ext { - media3_version = "1.4.1" - youtubeplayer_version = "11.1.0" - - firebase_version = "33.0.0" - - jsoup_version = '1.13.1' - - in_app_review = '2.0.1' - - extented_spans_version = "1.3.0" + // Android SDK versions + compile_sdk_version = 36 + target_sdk_version = 36 + min_sdk_version = 24 + java_version = JavaVersion.VERSION_17 configHelper = new ConfigHelper(projectDir, getCurrentFlavor()) - - zip_version = '2.6.3' - //testing - compose_ui_tooling = '1.7.8' - mockk_version = '1.13.12' - android_arch_version = '2.2.0' - junit_version = '4.13.2' } def getCurrentFlavor() { diff --git a/buildSrc/build.gradle b/buildSrc/build.gradle index f1d8de5cb..4532d0758 100644 --- a/buildSrc/build.gradle +++ b/buildSrc/build.gradle @@ -14,5 +14,5 @@ java { dependencies { implementation localGroovy() implementation gradleApi() - implementation 'org.yaml:snakeyaml:1.33' + implementation "org.yaml:snakeyaml:2.4" } diff --git a/core/build.gradle b/core/build.gradle index 2c2ac0858..76406318d 100644 --- a/core/build.gradle +++ b/core/build.gradle @@ -4,7 +4,7 @@ buildscript { } dependencies { - classpath 'org.yaml:snakeyaml:2.0' + classpath "org.yaml:snakeyaml:$snakeyaml_version" } } @@ -21,17 +21,17 @@ def config = configHelper.fetchConfig() def themeDirectory = config.getOrDefault("THEME_DIRECTORY", "openedx") android { - compileSdk 34 + namespace 'org.openedx.core' + compileSdkVersion compile_sdk_version defaultConfig { - minSdk 24 - targetSdk 34 + minSdk min_sdk_version + targetSdk target_sdk_version testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" consumerProguardFiles "consumer-rules.pro" } - namespace 'org.openedx.core' flavorDimensions += "env" productFlavors { @@ -76,11 +76,11 @@ android { } } compileOptions { - sourceCompatibility JavaVersion.VERSION_17 - targetCompatibility JavaVersion.VERSION_17 + sourceCompatibility java_version + targetCompatibility java_version } kotlinOptions { - jvmTarget = JavaVersion.VERSION_17 + jvmTarget = java_version freeCompilerArgs = List.of("-Xstring-concat=inline") } @@ -109,22 +109,22 @@ dependencies { api "com.google.android.play:review-ktx:$in_app_review" // Branch SDK Integration - api "io.branch.sdk.android:library:5.9.0" - api "com.google.android.gms:play-services-ads-identifier:18.1.0" - api "com.android.installreferrer:installreferrer:2.2" + api "io.branch.sdk.android:library:$branch_sdk_version" + api "com.google.android.gms:play-services-ads-identifier:$play_services_ads_identifier_version" + api "com.android.installreferrer:installreferrer:$install_referrer_version" // Zip api "net.lingala.zip4j:zip4j:$zip_version" // OpenEdx libs - api("com.github.openedx:openedx-app-foundation-android:1.0.1") + api("com.github.openedx:openedx-app-foundation-android:$openedx_foundation_version") // Preview debugApi "androidx.compose.ui:ui-tooling:$compose_ui_tooling" - testImplementation 'junit:junit:4.13.2' - androidTestImplementation 'androidx.test.ext:junit:1.2.1' - androidTestImplementation 'androidx.test.espresso:espresso-core:3.6.1' + testImplementation "junit:junit:$junit_version" + androidTestImplementation "androidx.test.ext:junit:$test_ext_version" + androidTestImplementation "androidx.test.espresso:espresso-core:$espresso_version" } def insertBuildConfigFields(currentFlavour, buildType) { diff --git a/core/src/main/java/org/openedx/core/AppDataConstants.kt b/core/src/main/java/org/openedx/core/AppDataConstants.kt index a9f22e573..cf6766ac1 100644 --- a/core/src/main/java/org/openedx/core/AppDataConstants.kt +++ b/core/src/main/java/org/openedx/core/AppDataConstants.kt @@ -6,7 +6,7 @@ object AppDataConstants { const val USER_MIN_YEAR = 13 const val USER_MAX_YEAR = 77 const val DEFAULT_MIME_TYPE = "image/jpeg" - val defaultLocale = Locale("en") + val defaultLocale: Locale = Locale.Builder().setLanguage("en").build() const val VIDEO_FORMAT_M3U8 = ".m3u8" const val VIDEO_FORMAT_MP4 = ".mp4" diff --git a/core/src/main/java/org/openedx/core/utils/LocaleUtils.kt b/core/src/main/java/org/openedx/core/utils/LocaleUtils.kt index b6ae624f5..2b22a00a5 100644 --- a/core/src/main/java/org/openedx/core/utils/LocaleUtils.kt +++ b/core/src/main/java/org/openedx/core/utils/LocaleUtils.kt @@ -37,21 +37,25 @@ object LocaleUtils { fun getCountryByCountryCode(code: String): String? { val countryISO = Locale.getISOCountries().firstOrNull { it == code } return countryISO?.let { - Locale("", it).getDisplayCountry(defaultLocale) + Locale.Builder().setRegion(it).build().getDisplayCountry(defaultLocale) } } fun getLanguageByLanguageCode(code: String): String? { val countryISO = Locale.getISOLanguages().firstOrNull { it == code } return countryISO?.let { - Locale(it, "").getDisplayLanguage(defaultLocale) + Locale.Builder().setLanguage(it).build().getDisplayLanguage(defaultLocale) } } private fun getAvailableCountries() = Locale.getISOCountries() .asSequence() .map { - RegistrationField.Option(it, Locale("", it).getDisplayCountry(defaultLocale), "") + RegistrationField.Option( + it, + Locale.Builder().setRegion(it).build().getDisplayCountry(defaultLocale), + "" + ) } .sortedBy { it.name } .toList() @@ -60,12 +64,16 @@ object LocaleUtils { .asSequence() .filter { it.length == 2 } .map { - RegistrationField.Option(it, Locale(it, "").getDisplayLanguage(defaultLocale), "") + RegistrationField.Option( + it, + Locale.Builder().setLanguage(it).build().getDisplayLanguage(defaultLocale), + "" + ) } .sortedBy { it.name } .toList() fun getDisplayLanguage(languageCode: String): String { - return Locale(languageCode, "").getDisplayLanguage(defaultLocale) + return Locale.Builder().setLanguage(languageCode).build().getDisplayLanguage(defaultLocale) } } 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 d9fe2f853..b401d0eb4 100644 --- a/core/src/main/java/org/openedx/core/utils/TimeUtils.kt +++ b/core/src/main/java/org/openedx/core/utils/TimeUtils.kt @@ -24,7 +24,7 @@ object TimeUtils { fun formatToString(context: Context, date: Date, useRelativeDates: Boolean): String { if (!useRelativeDates) { - val locale = Locale(Locale.getDefault().language) + val locale = Locale.Builder().setLanguage(Locale.getDefault().language).build() val dateFormat = DateFormat.getDateInstance(DateFormat.MEDIUM, locale) return dateFormat.format(date) } diff --git a/course/build.gradle b/course/build.gradle index 3b8096dc4..49dbc592f 100644 --- a/course/build.gradle +++ b/course/build.gradle @@ -6,31 +6,30 @@ plugins { } android { - compileSdk 34 + namespace 'org.openedx.course' + compileSdkVersion compile_sdk_version defaultConfig { - minSdk 24 - targetSdk 34 + minSdk min_sdk_version + targetSdk target_sdk_version testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" consumerProguardFiles "consumer-rules.pro" } - namespace 'org.openedx.course' buildTypes { release { - minifyEnabled true + minifyEnabled false proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro' } } compileOptions { - sourceCompatibility JavaVersion.VERSION_17 - targetCompatibility JavaVersion.VERSION_17 + sourceCompatibility java_version + targetCompatibility java_version } kotlinOptions { - jvmTarget = JavaVersion.VERSION_17 - freeCompilerArgs = List.of("-Xstring-concat=inline") + jvmTarget = java_version } buildFeatures { @@ -60,15 +59,17 @@ dependencies { implementation project(path: ':core') implementation project(path: ':discussion') implementation "com.pierfrancescosoffritti.androidyoutubeplayer:core:$youtubeplayer_version" + + // Media3 implementation "androidx.media3:media3-exoplayer:$media3_version" implementation "androidx.media3:media3-exoplayer-hls:$media3_version" implementation "androidx.media3:media3-ui:$media3_version" implementation "androidx.media3:media3-cast:$media3_version" implementation "me.saket.extendedspans:extendedspans:$extented_spans_version" - androidTestImplementation 'androidx.test.ext:junit:1.2.1' - androidTestImplementation 'androidx.test.espresso:espresso-core:3.6.1' testImplementation "junit:junit:$junit_version" + androidTestImplementation "androidx.test.ext:junit:$test_ext_version" + androidTestImplementation "androidx.test.espresso:espresso-core:$espresso_version" testImplementation "io.mockk:mockk:$mockk_version" testImplementation "io.mockk:mockk-android:$mockk_version" testImplementation "androidx.arch.core:core-testing:$android_arch_version" diff --git a/dashboard/build.gradle b/dashboard/build.gradle index 13119287f..a07b08958 100644 --- a/dashboard/build.gradle +++ b/dashboard/build.gradle @@ -5,32 +5,30 @@ plugins { } android { - compileSdk 34 + namespace 'org.openedx.dashboard' + compileSdkVersion compile_sdk_version defaultConfig { - minSdk 24 - targetSdk 34 + minSdk min_sdk_version + targetSdk target_sdk_version testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" consumerProguardFiles "consumer-rules.pro" } - namespace 'org.openedx.dashboard' buildTypes { release { - minifyEnabled true + minifyEnabled false proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro' } } - compileOptions { - sourceCompatibility JavaVersion.VERSION_17 - targetCompatibility JavaVersion.VERSION_17 + sourceCompatibility java_version + targetCompatibility java_version } kotlinOptions { - jvmTarget = JavaVersion.VERSION_17 - freeCompilerArgs = List.of("-Xstring-concat=inline") + jvmTarget = java_version } buildFeatures { @@ -55,10 +53,10 @@ android { dependencies { implementation project(path: ':core') - androidTestImplementation 'androidx.test.ext:junit:1.2.1' - androidTestImplementation 'androidx.test.espresso:espresso-core:3.6.1' testImplementation "junit:junit:$junit_version" testImplementation "io.mockk:mockk:$mockk_version" - testImplementation "io.mockk:mockk-android:$mockk_version" testImplementation "androidx.arch.core:core-testing:$android_arch_version" + testImplementation "org.jetbrains.kotlinx:kotlinx-coroutines-test:$kotlinx_coroutines_test_version" + androidTestImplementation "androidx.test.ext:junit:$test_ext_version" + androidTestImplementation "androidx.test.espresso:espresso-core:$espresso_version" } \ No newline at end of file diff --git a/discovery/build.gradle b/discovery/build.gradle index d9c4419fc..3264c8b2b 100644 --- a/discovery/build.gradle +++ b/discovery/build.gradle @@ -7,32 +7,30 @@ plugins { } android { - compileSdk 34 + namespace 'org.openedx.discovery' + compileSdkVersion compile_sdk_version defaultConfig { - minSdk 24 - targetSdk 34 + minSdk min_sdk_version + targetSdk target_sdk_version testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" consumerProguardFiles "consumer-rules.pro" } - namespace 'org.openedx.discovery' buildTypes { release { - minifyEnabled true + minifyEnabled false proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro' } } - compileOptions { - sourceCompatibility JavaVersion.VERSION_17 - targetCompatibility JavaVersion.VERSION_17 + sourceCompatibility java_version + targetCompatibility java_version } kotlinOptions { - jvmTarget = JavaVersion.VERSION_17 - freeCompilerArgs = List.of("-Xstring-concat=inline") + jvmTarget = java_version } buildFeatures { @@ -57,14 +55,12 @@ android { dependencies { implementation project(path: ':core') - ksp "androidx.room:room-compiler:$room_version" - implementation 'androidx.activity:activity-compose:1.8.1' + implementation "androidx.activity:activity-compose:$activity_compose_version" - androidTestImplementation 'androidx.test.ext:junit:1.2.1' - androidTestImplementation 'androidx.test.espresso:espresso-core:3.6.1' testImplementation "junit:junit:$junit_version" testImplementation "io.mockk:mockk:$mockk_version" - testImplementation "io.mockk:mockk-android:$mockk_version" testImplementation "androidx.arch.core:core-testing:$android_arch_version" - + testImplementation "org.jetbrains.kotlinx:kotlinx-coroutines-test:$kotlinx_coroutines_test_version" + androidTestImplementation "androidx.test.ext:junit:$test_ext_version" + androidTestImplementation "androidx.test.espresso:espresso-core:$espresso_version" } diff --git a/discussion/build.gradle b/discussion/build.gradle index 5442a57b2..cae8f215a 100644 --- a/discussion/build.gradle +++ b/discussion/build.gradle @@ -7,11 +7,11 @@ plugins { android { namespace 'org.openedx.discussion' - compileSdk 34 + compileSdkVersion compile_sdk_version defaultConfig { - minSdk 24 - targetSdk 34 + minSdk min_sdk_version + targetSdk target_sdk_version testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" consumerProguardFiles "consumer-rules.pro" @@ -19,17 +19,16 @@ android { buildTypes { release { - minifyEnabled true + minifyEnabled false proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro' } } compileOptions { - sourceCompatibility JavaVersion.VERSION_17 - targetCompatibility JavaVersion.VERSION_17 + sourceCompatibility java_version + targetCompatibility java_version } kotlinOptions { - jvmTarget = JavaVersion.VERSION_17 - freeCompilerArgs = List.of("-Xstring-concat=inline") + jvmTarget = java_version } buildFeatures { @@ -54,10 +53,10 @@ android { dependencies { implementation project(path: ':core') - androidTestImplementation 'androidx.test.ext:junit:1.2.1' - androidTestImplementation 'androidx.test.espresso:espresso-core:3.6.1' testImplementation "junit:junit:$junit_version" testImplementation "io.mockk:mockk:$mockk_version" - testImplementation "io.mockk:mockk-android:$mockk_version" testImplementation "androidx.arch.core:core-testing:$android_arch_version" + testImplementation "org.jetbrains.kotlinx:kotlinx-coroutines-test:$kotlinx_coroutines_test_version" + androidTestImplementation "androidx.test.ext:junit:$test_ext_version" + androidTestImplementation "androidx.test.espresso:espresso-core:$espresso_version" } \ No newline at end of file diff --git a/downloads/build.gradle b/downloads/build.gradle index df169ecd9..cd463eecf 100644 --- a/downloads/build.gradle +++ b/downloads/build.gradle @@ -6,32 +6,31 @@ plugins { } android { - compileSdk 34 + namespace 'org.openedx.downloads' + compileSdkVersion compile_sdk_version defaultConfig { - minSdk 24 - targetSdk 34 + minSdk min_sdk_version + targetSdk target_sdk_version testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" consumerProguardFiles "consumer-rules.pro" } - namespace 'org.openedx.downloads' buildTypes { release { - minifyEnabled true + minifyEnabled false proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro' } } compileOptions { - sourceCompatibility JavaVersion.VERSION_17 - targetCompatibility JavaVersion.VERSION_17 + sourceCompatibility java_version + targetCompatibility java_version } kotlinOptions { - jvmTarget = JavaVersion.VERSION_17 - freeCompilerArgs = List.of("-Xstring-concat=inline") + jvmTarget = java_version } buildFeatures { @@ -56,10 +55,10 @@ android { dependencies { implementation project(path: ':core') - androidTestImplementation 'androidx.test.ext:junit:1.2.1' - androidTestImplementation 'androidx.test.espresso:espresso-core:3.6.1' testImplementation "junit:junit:$junit_version" testImplementation "io.mockk:mockk:$mockk_version" - testImplementation "io.mockk:mockk-android:$mockk_version" testImplementation "androidx.arch.core:core-testing:$android_arch_version" + testImplementation "org.jetbrains.kotlinx:kotlinx-coroutines-test:$kotlinx_coroutines_test_version" + androidTestImplementation "androidx.test.ext:junit:$test_ext_version" + androidTestImplementation "androidx.test.espresso:espresso-core:$espresso_version" } diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index ec34fd6a7..0f37100ea 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,6 +1,6 @@ -#Fri May 03 13:24:00 EEST 2024 +#Mon Aug 11 14:17:42 EEST 2025 distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-8.7-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-8.13-bin.zip zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists diff --git a/profile/build.gradle b/profile/build.gradle index a1b894421..83897b149 100644 --- a/profile/build.gradle +++ b/profile/build.gradle @@ -7,11 +7,11 @@ plugins { android { namespace 'org.openedx.profile' - compileSdk 34 + compileSdkVersion compile_sdk_version defaultConfig { - minSdk 24 - targetSdk 34 + minSdk min_sdk_version + targetSdk target_sdk_version testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" consumerProguardFiles "consumer-rules.pro" @@ -19,18 +19,16 @@ android { buildTypes { release { - minifyEnabled true + minifyEnabled false proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro' } } - compileOptions { - sourceCompatibility JavaVersion.VERSION_17 - targetCompatibility JavaVersion.VERSION_17 + sourceCompatibility java_version + targetCompatibility java_version } kotlinOptions { - jvmTarget = JavaVersion.VERSION_17 - freeCompilerArgs = List.of("-Xstring-concat=inline") + jvmTarget = java_version } buildFeatures { @@ -55,10 +53,10 @@ android { dependencies { implementation project(path: ":core") - androidTestImplementation 'androidx.test.ext:junit:1.2.1' - androidTestImplementation 'androidx.test.espresso:espresso-core:3.6.1' testImplementation "junit:junit:$junit_version" testImplementation "io.mockk:mockk:$mockk_version" - testImplementation "io.mockk:mockk-android:$mockk_version" testImplementation "androidx.arch.core:core-testing:$android_arch_version" + testImplementation "org.jetbrains.kotlinx:kotlinx-coroutines-test:$kotlinx_coroutines_test_version" + androidTestImplementation "androidx.test.ext:junit:$test_ext_version" + androidTestImplementation "androidx.test.espresso:espresso-core:$espresso_version" } \ No newline at end of file diff --git a/settings.gradle b/settings.gradle index bdb401703..a58940420 100644 --- a/settings.gradle +++ b/settings.gradle @@ -12,7 +12,7 @@ pluginManagement { } } dependencies { - classpath("com.android.tools:r8:8.5.35") + classpath("com.android.tools:r8:8.12.14") } } } diff --git a/whatsnew/build.gradle b/whatsnew/build.gradle index 59a5e14cc..cde0c7166 100644 --- a/whatsnew/build.gradle +++ b/whatsnew/build.gradle @@ -7,11 +7,11 @@ plugins { android { namespace 'org.openedx.whatsnew' - compileSdk 34 + compileSdkVersion compile_sdk_version defaultConfig { - minSdk 24 - targetSdk 34 + minSdk min_sdk_version + targetSdk target_sdk_version testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" consumerProguardFiles "consumer-rules.pro" @@ -19,18 +19,16 @@ android { buildTypes { release { - minifyEnabled true + minifyEnabled false proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro' } } - compileOptions { - sourceCompatibility JavaVersion.VERSION_17 - targetCompatibility JavaVersion.VERSION_17 + sourceCompatibility java_version + targetCompatibility java_version } - kotlinOptions { - jvmTarget = JavaVersion.VERSION_17 + jvmTarget = java_version freeCompilerArgs = List.of("-Xstring-concat=inline") } @@ -51,15 +49,15 @@ android { dimension 'env' } } -} -dependencies { - implementation project(path: ":core") + dependencies { + implementation project(path: ":core") - androidTestImplementation 'androidx.test.ext:junit:1.2.1' - androidTestImplementation 'androidx.test.espresso:espresso-core:3.6.1' - testImplementation "junit:junit:$junit_version" - testImplementation "io.mockk:mockk:$mockk_version" - testImplementation "io.mockk:mockk-android:$mockk_version" - testImplementation "androidx.arch.core:core-testing:$android_arch_version" + testImplementation "junit:junit:$junit_version" + testImplementation "io.mockk:mockk:$mockk_version" + testImplementation "androidx.arch.core:core-testing:$android_arch_version" + testImplementation "org.jetbrains.kotlinx:kotlinx-coroutines-test:$kotlinx_coroutines_test_version" + androidTestImplementation "androidx.test.ext:junit:$test_ext_version" + androidTestImplementation "androidx.test.espresso:espresso-core:$espresso_version" + } } \ No newline at end of file From 4df0b0275fec1b5308fa92c3d527b45e48fb2b58 Mon Sep 17 00:00:00 2001 From: PavloNetrebchuk <141041606+PavloNetrebchuk@users.noreply.github.com> Date: Mon, 18 Aug 2025 13:13:57 +0300 Subject: [PATCH 19/24] feat: [FC-0092] Course progress tab (#448) * feat: progress tab UI * feat: progress tab logic * feat: cache first logic * feat: color coding * fix: changes according demo feedback * fix: changes according demo feedback * feat: color coding * feat: separator between assignment policies on the progress * feat: progressTabClickedEvent * feat: defaultColors changed --- .../org.openedx.app.room.AppDatabase/3.json | 1198 +++++++++++++++++ .../java/org/openedx/app/di/ScreenModule.kt | 8 + .../java/org/openedx/app/room/AppDatabase.kt | 9 +- .../org/openedx/app/room/DatabaseManager.kt | 3 +- .../org/openedx/core/NoContentScreenType.kt | 6 +- .../org/openedx/core/data/api/CourseApi.kt | 6 + .../core/data/model/CourseProgressResponse.kt | 283 ++++ .../data/model/room/CourseProgressEntity.kt | 236 ++++ .../openedx/core/data/storage/CourseDao.kt | 24 +- .../core/domain/model/CourseProgress.kt | 135 ++ .../core/system/notifier/CourseNotifier.kt | 1 + .../core/system/notifier/RefreshProgress.kt | 3 + .../org/openedx/core/ui/theme/AppColors.kt | 3 +- .../java/org/openedx/core/ui/theme/Theme.kt | 6 +- core/src/main/res/values/strings.xml | 1 + .../org/openedx/core/ui/theme/Colors.kt | 2 + .../data/repository/CourseRepository.kt | 19 +- .../course/data/storage/CourseConverter.kt | 56 +- .../domain/interactor/CourseInteractor.kt | 12 +- .../course/presentation/CourseAnalytics.kt | 4 + .../container/CourseContainerFragment.kt | 15 +- .../container/CourseContainerTab.kt | 4 +- .../container/CourseContainerViewModel.kt | 14 +- .../progress/CourseProgressScreen.kt | 547 ++++++++ .../progress/CourseProgressUIState.kt | 9 + .../progress/CourseProgressViewModel.kt | 72 + .../main/res/drawable/ic_course_marker.xml | 12 + course/src/main/res/values/strings.xml | 16 +- 28 files changed, 2656 insertions(+), 48 deletions(-) create mode 100644 app/schemas/org.openedx.app.room.AppDatabase/3.json create mode 100644 core/src/main/java/org/openedx/core/data/model/CourseProgressResponse.kt create mode 100644 core/src/main/java/org/openedx/core/data/model/room/CourseProgressEntity.kt create mode 100644 core/src/main/java/org/openedx/core/domain/model/CourseProgress.kt create mode 100644 core/src/main/java/org/openedx/core/system/notifier/RefreshProgress.kt create mode 100644 course/src/main/java/org/openedx/course/presentation/progress/CourseProgressScreen.kt create mode 100644 course/src/main/java/org/openedx/course/presentation/progress/CourseProgressUIState.kt create mode 100644 course/src/main/java/org/openedx/course/presentation/progress/CourseProgressViewModel.kt create mode 100644 course/src/main/res/drawable/ic_course_marker.xml diff --git a/app/schemas/org.openedx.app.room.AppDatabase/3.json b/app/schemas/org.openedx.app.room.AppDatabase/3.json new file mode 100644 index 000000000..0b47d8504 --- /dev/null +++ b/app/schemas/org.openedx.app.room.AppDatabase/3.json @@ -0,0 +1,1198 @@ +{ + "formatVersion": 1, + "database": { + "version": 3, + "identityHash": "bcf7a22441e12e4c8b6fb332754827bf", + "entities": [ + { + "tableName": "course_discovery_table", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `blocksUrl` TEXT NOT NULL, `courseId` TEXT NOT NULL, `effort` TEXT NOT NULL, `enrollmentStart` TEXT NOT NULL, `enrollmentEnd` TEXT NOT NULL, `hidden` INTEGER NOT NULL, `invitationOnly` INTEGER NOT NULL, `mobileAvailable` INTEGER NOT NULL, `name` TEXT NOT NULL, `number` TEXT NOT NULL, `org` TEXT NOT NULL, `pacing` TEXT NOT NULL, `shortDescription` TEXT NOT NULL, `start` TEXT NOT NULL, `end` TEXT NOT NULL, `startDisplay` TEXT NOT NULL, `startType` TEXT NOT NULL, `overview` TEXT NOT NULL, `isEnrolled` INTEGER NOT NULL, `bannerImage` TEXT, `courseImage` TEXT, `courseVideo` TEXT, `image` TEXT, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "blocksUrl", + "columnName": "blocksUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "courseId", + "columnName": "courseId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "effort", + "columnName": "effort", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "enrollmentStart", + "columnName": "enrollmentStart", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "enrollmentEnd", + "columnName": "enrollmentEnd", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "hidden", + "columnName": "hidden", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "invitationOnly", + "columnName": "invitationOnly", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "mobileAvailable", + "columnName": "mobileAvailable", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "number", + "columnName": "number", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "org", + "columnName": "org", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "pacing", + "columnName": "pacing", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "shortDescription", + "columnName": "shortDescription", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "start", + "columnName": "start", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "end", + "columnName": "end", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "startDisplay", + "columnName": "startDisplay", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "startType", + "columnName": "startType", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "overview", + "columnName": "overview", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "isEnrolled", + "columnName": "isEnrolled", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "media.bannerImage", + "columnName": "bannerImage", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "media.courseImage", + "columnName": "courseImage", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "media.courseVideo", + "columnName": "courseVideo", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "media.image", + "columnName": "image", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "course_enrolled_table", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`courseId` TEXT NOT NULL, `auditAccessExpires` TEXT NOT NULL, `created` TEXT NOT NULL, `mode` TEXT NOT NULL, `isActive` INTEGER NOT NULL, `id` TEXT NOT NULL, `name` TEXT NOT NULL, `number` TEXT NOT NULL, `org` TEXT NOT NULL, `start` TEXT NOT NULL, `startDisplay` TEXT NOT NULL, `startType` TEXT NOT NULL, `end` TEXT NOT NULL, `dynamicUpgradeDeadline` TEXT NOT NULL, `subscriptionId` TEXT NOT NULL, `course_image_link` TEXT NOT NULL, `courseAbout` TEXT NOT NULL, `courseUpdates` TEXT NOT NULL, `courseHandouts` TEXT NOT NULL, `discussionUrl` TEXT NOT NULL, `videoOutline` TEXT NOT NULL, `isSelfPaced` INTEGER NOT NULL, `hasAccess` INTEGER, `errorCode` TEXT, `developerMessage` TEXT, `userMessage` TEXT, `additionalContextUserMessage` TEXT, `userFragment` TEXT, `bannerImage` TEXT, `courseImage` TEXT, `courseVideo` TEXT, `image` TEXT, `facebook` TEXT NOT NULL, `twitter` TEXT NOT NULL, `certificateURL` TEXT, `assignments_completed` INTEGER NOT NULL, `total_assignments_count` INTEGER NOT NULL, `lastVisitedModuleId` TEXT, `lastVisitedModulePath` TEXT, `lastVisitedBlockId` TEXT, `lastVisitedUnitDisplayName` TEXT, `futureAssignments` TEXT, `pastAssignments` TEXT, PRIMARY KEY(`courseId`))", + "fields": [ + { + "fieldPath": "courseId", + "columnName": "courseId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "auditAccessExpires", + "columnName": "auditAccessExpires", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "created", + "columnName": "created", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "mode", + "columnName": "mode", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "isActive", + "columnName": "isActive", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "course.id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "course.name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "course.number", + "columnName": "number", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "course.org", + "columnName": "org", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "course.start", + "columnName": "start", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "course.startDisplay", + "columnName": "startDisplay", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "course.startType", + "columnName": "startType", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "course.end", + "columnName": "end", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "course.dynamicUpgradeDeadline", + "columnName": "dynamicUpgradeDeadline", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "course.subscriptionId", + "columnName": "subscriptionId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "course.courseImage", + "columnName": "course_image_link", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "course.courseAbout", + "columnName": "courseAbout", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "course.courseUpdates", + "columnName": "courseUpdates", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "course.courseHandouts", + "columnName": "courseHandouts", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "course.discussionUrl", + "columnName": "discussionUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "course.videoOutline", + "columnName": "videoOutline", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "course.isSelfPaced", + "columnName": "isSelfPaced", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "course.coursewareAccess.hasAccess", + "columnName": "hasAccess", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "course.coursewareAccess.errorCode", + "columnName": "errorCode", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "course.coursewareAccess.developerMessage", + "columnName": "developerMessage", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "course.coursewareAccess.userMessage", + "columnName": "userMessage", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "course.coursewareAccess.additionalContextUserMessage", + "columnName": "additionalContextUserMessage", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "course.coursewareAccess.userFragment", + "columnName": "userFragment", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "course.media.bannerImage", + "columnName": "bannerImage", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "course.media.courseImage", + "columnName": "courseImage", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "course.media.courseVideo", + "columnName": "courseVideo", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "course.media.image", + "columnName": "image", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "course.courseSharingUtmParameters.facebook", + "columnName": "facebook", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "course.courseSharingUtmParameters.twitter", + "columnName": "twitter", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "certificate.certificateURL", + "columnName": "certificateURL", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "progress.assignmentsCompleted", + "columnName": "assignments_completed", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "progress.totalAssignmentsCount", + "columnName": "total_assignments_count", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "courseStatus.lastVisitedModuleId", + "columnName": "lastVisitedModuleId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "courseStatus.lastVisitedModulePath", + "columnName": "lastVisitedModulePath", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "courseStatus.lastVisitedBlockId", + "columnName": "lastVisitedBlockId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "courseStatus.lastVisitedUnitDisplayName", + "columnName": "lastVisitedUnitDisplayName", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "courseAssignments.futureAssignments", + "columnName": "futureAssignments", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "courseAssignments.pastAssignments", + "columnName": "pastAssignments", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "courseId" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "course_structure_table", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`root` TEXT NOT NULL, `id` TEXT NOT NULL, `blocks` TEXT NOT NULL, `name` TEXT NOT NULL, `number` TEXT NOT NULL, `org` TEXT NOT NULL, `start` TEXT, `startDisplay` TEXT NOT NULL, `startType` TEXT NOT NULL, `end` TEXT, `isSelfPaced` INTEGER NOT NULL, `hasAccess` INTEGER, `errorCode` TEXT, `developerMessage` TEXT, `userMessage` TEXT, `additionalContextUserMessage` TEXT, `userFragment` TEXT, `bannerImage` TEXT, `courseImage` TEXT, `courseVideo` TEXT, `image` TEXT, `certificateURL` TEXT, `assignments_completed` INTEGER NOT NULL, `total_assignments_count` INTEGER NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "root", + "columnName": "root", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "blocks", + "columnName": "blocks", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "number", + "columnName": "number", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "org", + "columnName": "org", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "start", + "columnName": "start", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "startDisplay", + "columnName": "startDisplay", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "startType", + "columnName": "startType", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "end", + "columnName": "end", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "isSelfPaced", + "columnName": "isSelfPaced", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "coursewareAccess.hasAccess", + "columnName": "hasAccess", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "coursewareAccess.errorCode", + "columnName": "errorCode", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "coursewareAccess.developerMessage", + "columnName": "developerMessage", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "coursewareAccess.userMessage", + "columnName": "userMessage", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "coursewareAccess.additionalContextUserMessage", + "columnName": "additionalContextUserMessage", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "coursewareAccess.userFragment", + "columnName": "userFragment", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "media.bannerImage", + "columnName": "bannerImage", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "media.courseImage", + "columnName": "courseImage", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "media.courseVideo", + "columnName": "courseVideo", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "media.image", + "columnName": "image", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "certificate.certificateURL", + "columnName": "certificateURL", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "progress.assignmentsCompleted", + "columnName": "assignments_completed", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "progress.totalAssignmentsCount", + "columnName": "total_assignments_count", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "download_model", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `title` TEXT NOT NULL, `courseId` TEXT NOT NULL, `size` INTEGER NOT NULL, `path` TEXT NOT NULL, `url` TEXT NOT NULL, `type` TEXT NOT NULL, `downloadedState` TEXT NOT NULL, `lastModified` TEXT, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "courseId", + "columnName": "courseId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "size", + "columnName": "size", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "path", + "columnName": "path", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "downloadedState", + "columnName": "downloadedState", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lastModified", + "columnName": "lastModified", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "offline_x_block_progress_table", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `courseId` TEXT NOT NULL, `url` TEXT NOT NULL, `type` TEXT NOT NULL, `data` TEXT NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "blockId", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "courseId", + "columnName": "courseId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "jsonProgress.url", + "columnName": "url", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "jsonProgress.type", + "columnName": "type", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "jsonProgress.data", + "columnName": "data", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "course_calendar_event_table", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`event_id` INTEGER NOT NULL, `course_id` TEXT NOT NULL, PRIMARY KEY(`event_id`))", + "fields": [ + { + "fieldPath": "eventId", + "columnName": "event_id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "courseId", + "columnName": "course_id", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "event_id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "course_calendar_state_table", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`course_id` TEXT NOT NULL, `checksum` INTEGER NOT NULL, `is_course_sync_enabled` INTEGER NOT NULL, PRIMARY KEY(`course_id`))", + "fields": [ + { + "fieldPath": "courseId", + "columnName": "course_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "checksum", + "columnName": "checksum", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isCourseSyncEnabled", + "columnName": "is_course_sync_enabled", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "course_id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "download_course_preview_table", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`course_id` TEXT NOT NULL, `course_name` TEXT, `course_image` TEXT, `total_size` INTEGER, PRIMARY KEY(`course_id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "course_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "course_name", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "image", + "columnName": "course_image", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "totalSize", + "columnName": "total_size", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "course_id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "course_enrollment_details_table", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `courseUpdates` TEXT NOT NULL, `courseHandouts` TEXT NOT NULL, `discussionUrl` TEXT NOT NULL, `hasUnmetPrerequisites` INTEGER NOT NULL, `isTooEarly` INTEGER NOT NULL, `isStaff` INTEGER NOT NULL, `auditAccessExpires` TEXT, `hasAccess` INTEGER, `errorCode` TEXT, `developerMessage` TEXT, `userMessage` TEXT, `additionalContextUserMessage` TEXT, `userFragment` TEXT, `certificateURL` TEXT, `created` TEXT, `mode` TEXT, `isActive` INTEGER NOT NULL, `upgradeDeadline` TEXT, `name` TEXT NOT NULL, `number` TEXT NOT NULL, `org` TEXT NOT NULL, `startDisplay` TEXT NOT NULL, `startType` TEXT NOT NULL, `isSelfPaced` INTEGER NOT NULL, `courseAbout` TEXT NOT NULL, `bannerImage` TEXT, `courseImage` TEXT, `courseVideo` TEXT, `image` TEXT, `facebook` TEXT NOT NULL, `twitter` TEXT NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "courseUpdates", + "columnName": "courseUpdates", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "courseHandouts", + "columnName": "courseHandouts", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "discussionUrl", + "columnName": "discussionUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "courseAccessDetails.hasUnmetPrerequisites", + "columnName": "hasUnmetPrerequisites", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "courseAccessDetails.isTooEarly", + "columnName": "isTooEarly", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "courseAccessDetails.isStaff", + "columnName": "isStaff", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "courseAccessDetails.auditAccessExpires", + "columnName": "auditAccessExpires", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "courseAccessDetails.coursewareAccess.hasAccess", + "columnName": "hasAccess", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "courseAccessDetails.coursewareAccess.errorCode", + "columnName": "errorCode", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "courseAccessDetails.coursewareAccess.developerMessage", + "columnName": "developerMessage", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "courseAccessDetails.coursewareAccess.userMessage", + "columnName": "userMessage", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "courseAccessDetails.coursewareAccess.additionalContextUserMessage", + "columnName": "additionalContextUserMessage", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "courseAccessDetails.coursewareAccess.userFragment", + "columnName": "userFragment", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "certificate.certificateURL", + "columnName": "certificateURL", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "enrollmentDetails.created", + "columnName": "created", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "enrollmentDetails.mode", + "columnName": "mode", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "enrollmentDetails.isActive", + "columnName": "isActive", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "enrollmentDetails.upgradeDeadline", + "columnName": "upgradeDeadline", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "courseInfoOverview.name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "courseInfoOverview.number", + "columnName": "number", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "courseInfoOverview.org", + "columnName": "org", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "courseInfoOverview.startDisplay", + "columnName": "startDisplay", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "courseInfoOverview.startType", + "columnName": "startType", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "courseInfoOverview.isSelfPaced", + "columnName": "isSelfPaced", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "courseInfoOverview.courseAbout", + "columnName": "courseAbout", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "courseInfoOverview.media.bannerImage", + "columnName": "bannerImage", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "courseInfoOverview.media.courseImage", + "columnName": "courseImage", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "courseInfoOverview.media.courseVideo", + "columnName": "courseVideo", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "courseInfoOverview.media.image", + "columnName": "image", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "courseInfoOverview.courseSharingUtmParameters.facebook", + "columnName": "facebook", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "courseInfoOverview.courseSharingUtmParameters.twitter", + "columnName": "twitter", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "course_progress_table", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`courseId` TEXT NOT NULL, `verifiedMode` TEXT NOT NULL, `accessExpiration` TEXT NOT NULL, `creditCourseRequirements` TEXT NOT NULL, `end` TEXT NOT NULL, `enrollmentMode` TEXT NOT NULL, `hasScheduledContent` INTEGER NOT NULL, `sectionScores` TEXT NOT NULL, `studioUrl` TEXT NOT NULL, `username` TEXT NOT NULL, `userHasPassingGrade` INTEGER NOT NULL, `disableProgressGraph` INTEGER NOT NULL, `certificate_certStatus` TEXT, `certificate_certWebViewUrl` TEXT, `certificate_downloadUrl` TEXT, `certificate_certificateAvailableDate` TEXT, `completion_completeCount` INTEGER, `completion_incompleteCount` INTEGER, `completion_lockedCount` INTEGER, `grade_letterGrade` TEXT, `grade_percent` REAL, `grade_isPassing` INTEGER, `grading_assignmentPolicies` TEXT, `grading_gradeRange` TEXT, `grading_assignmentColors` TEXT, `verification_link` TEXT, `verification_status` TEXT, `verification_statusDate` TEXT, PRIMARY KEY(`courseId`))", + "fields": [ + { + "fieldPath": "courseId", + "columnName": "courseId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "verifiedMode", + "columnName": "verifiedMode", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "accessExpiration", + "columnName": "accessExpiration", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "creditCourseRequirements", + "columnName": "creditCourseRequirements", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "end", + "columnName": "end", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "enrollmentMode", + "columnName": "enrollmentMode", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "hasScheduledContent", + "columnName": "hasScheduledContent", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "sectionScores", + "columnName": "sectionScores", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "studioUrl", + "columnName": "studioUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "username", + "columnName": "username", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "userHasPassingGrade", + "columnName": "userHasPassingGrade", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "disableProgressGraph", + "columnName": "disableProgressGraph", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "certificateData.certStatus", + "columnName": "certificate_certStatus", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "certificateData.certWebViewUrl", + "columnName": "certificate_certWebViewUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "certificateData.downloadUrl", + "columnName": "certificate_downloadUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "certificateData.certificateAvailableDate", + "columnName": "certificate_certificateAvailableDate", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "completionSummary.completeCount", + "columnName": "completion_completeCount", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "completionSummary.incompleteCount", + "columnName": "completion_incompleteCount", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "completionSummary.lockedCount", + "columnName": "completion_lockedCount", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "courseGrade.letterGrade", + "columnName": "grade_letterGrade", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "courseGrade.percent", + "columnName": "grade_percent", + "affinity": "REAL", + "notNull": false + }, + { + "fieldPath": "courseGrade.isPassing", + "columnName": "grade_isPassing", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "gradingPolicy.assignmentPolicies", + "columnName": "grading_assignmentPolicies", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "gradingPolicy.gradeRange", + "columnName": "grading_gradeRange", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "gradingPolicy.assignmentColors", + "columnName": "grading_assignmentColors", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "verificationData.link", + "columnName": "verification_link", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "verificationData.status", + "columnName": "verification_status", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "verificationData.statusDate", + "columnName": "verification_statusDate", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "courseId" + ] + }, + "indices": [], + "foreignKeys": [] + } + ], + "views": [], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'bcf7a22441e12e4c8b6fb332754827bf')" + ] + } +} \ No newline at end of file diff --git a/app/src/main/java/org/openedx/app/di/ScreenModule.kt b/app/src/main/java/org/openedx/app/di/ScreenModule.kt index 464007259..7f016ace9 100644 --- a/app/src/main/java/org/openedx/app/di/ScreenModule.kt +++ b/app/src/main/java/org/openedx/app/di/ScreenModule.kt @@ -23,6 +23,7 @@ import org.openedx.course.presentation.dates.CourseDatesViewModel import org.openedx.course.presentation.handouts.HandoutsViewModel import org.openedx.course.presentation.offline.CourseOfflineViewModel import org.openedx.course.presentation.outline.CourseOutlineViewModel +import org.openedx.course.presentation.progress.CourseProgressViewModel import org.openedx.course.presentation.section.CourseSectionViewModel import org.openedx.course.presentation.unit.container.CourseUnitContainerViewModel import org.openedx.course.presentation.unit.html.HtmlUnitViewModel @@ -495,6 +496,13 @@ val screenModule = module { get(), ) } + viewModel { (courseId: String) -> + CourseProgressViewModel( + courseId, + get(), + get() + ) + } single { DownloadRepository( diff --git a/app/src/main/java/org/openedx/app/room/AppDatabase.kt b/app/src/main/java/org/openedx/app/room/AppDatabase.kt index bfdcee43f..b5dfde4da 100644 --- a/app/src/main/java/org/openedx/app/room/AppDatabase.kt +++ b/app/src/main/java/org/openedx/app/room/AppDatabase.kt @@ -7,6 +7,7 @@ import androidx.room.TypeConverters import org.openedx.core.data.model.room.CourseCalendarEventEntity import org.openedx.core.data.model.room.CourseCalendarStateEntity import org.openedx.core.data.model.room.CourseEnrollmentDetailsEntity +import org.openedx.core.data.model.room.CourseProgressEntity import org.openedx.core.data.model.room.CourseStructureEntity import org.openedx.core.data.model.room.DownloadCoursePreview import org.openedx.core.data.model.room.OfflineXBlockProgress @@ -21,7 +22,7 @@ import org.openedx.discovery.data.converter.DiscoveryConverter import org.openedx.discovery.data.model.room.CourseEntity import org.openedx.discovery.data.storage.DiscoveryDao -const val DATABASE_VERSION = 2 +const val DATABASE_VERSION = 3 const val DATABASE_NAME = "OpenEdX_db" @Database( @@ -34,10 +35,12 @@ const val DATABASE_NAME = "OpenEdX_db" CourseCalendarEventEntity::class, CourseCalendarStateEntity::class, DownloadCoursePreview::class, - CourseEnrollmentDetailsEntity::class + CourseEnrollmentDetailsEntity::class, + CourseProgressEntity::class, ], autoMigrations = [ - AutoMigration(1, DATABASE_VERSION) + AutoMigration(1, 2), + AutoMigration(2, DATABASE_VERSION), ], version = DATABASE_VERSION ) diff --git a/app/src/main/java/org/openedx/app/room/DatabaseManager.kt b/app/src/main/java/org/openedx/app/room/DatabaseManager.kt index d24eb54f9..0dd6ce937 100644 --- a/app/src/main/java/org/openedx/app/room/DatabaseManager.kt +++ b/app/src/main/java/org/openedx/app/room/DatabaseManager.kt @@ -17,8 +17,7 @@ class DatabaseManager( ) : DatabaseManager { override fun clearTables() { CoroutineScope(Dispatchers.IO).launch { - courseDao.clearCachedData() - courseDao.clearEnrollmentCachedData() + courseDao.clearCourseData() dashboardDao.clearCachedData() downloadDao.clearOfflineProgress() discoveryDao.clearCachedData() diff --git a/core/src/main/java/org/openedx/core/NoContentScreenType.kt b/core/src/main/java/org/openedx/core/NoContentScreenType.kt index 88e8ad94b..1b9dcafab 100644 --- a/core/src/main/java/org/openedx/core/NoContentScreenType.kt +++ b/core/src/main/java/org/openedx/core/NoContentScreenType.kt @@ -27,5 +27,9 @@ enum class NoContentScreenType( COURSE_ANNOUNCEMENTS( iconResId = R.drawable.core_ic_no_announcements, messageResId = R.string.core_no_announcements - ) + ), + COURSE_PROGRESS( + iconResId = R.drawable.core_ic_no_content, + messageResId = R.string.core_no_progress + ), } diff --git a/core/src/main/java/org/openedx/core/data/api/CourseApi.kt b/core/src/main/java/org/openedx/core/data/api/CourseApi.kt index 0790e3eba..d6e44cfe2 100644 --- a/core/src/main/java/org/openedx/core/data/api/CourseApi.kt +++ b/core/src/main/java/org/openedx/core/data/api/CourseApi.kt @@ -8,6 +8,7 @@ import org.openedx.core.data.model.CourseDates import org.openedx.core.data.model.CourseDatesBannerInfo import org.openedx.core.data.model.CourseEnrollmentDetails import org.openedx.core.data.model.CourseEnrollments +import org.openedx.core.data.model.CourseProgressResponse import org.openedx.core.data.model.CourseStructureModel import org.openedx.core.data.model.DownloadCoursePreview import org.openedx.core.data.model.EnrollmentStatus @@ -109,4 +110,9 @@ interface CourseApi { suspend fun getDownloadCoursesPreview( @Path("username") username: String ): List + + @GET("/api/course_home/progress/{course_id}") + suspend fun getCourseProgress( + @Path("course_id") courseId: String, + ): CourseProgressResponse } diff --git a/core/src/main/java/org/openedx/core/data/model/CourseProgressResponse.kt b/core/src/main/java/org/openedx/core/data/model/CourseProgressResponse.kt new file mode 100644 index 000000000..bf31419e6 --- /dev/null +++ b/core/src/main/java/org/openedx/core/data/model/CourseProgressResponse.kt @@ -0,0 +1,283 @@ +package org.openedx.core.data.model + +import androidx.compose.ui.graphics.Color +import androidx.core.graphics.toColorInt +import com.google.gson.annotations.SerializedName +import org.openedx.core.data.model.room.CertificateDataDb +import org.openedx.core.data.model.room.CompletionSummaryDb +import org.openedx.core.data.model.room.CourseGradeDb +import org.openedx.core.data.model.room.CourseProgressEntity +import org.openedx.core.data.model.room.GradingPolicyDb +import org.openedx.core.data.model.room.SectionScoreDb +import org.openedx.core.data.model.room.VerificationDataDb +import org.openedx.core.domain.model.CourseProgress + +data class CourseProgressResponse( + @SerializedName("verified_mode") val verifiedMode: String?, + @SerializedName("access_expiration") val accessExpiration: String?, + @SerializedName("certificate_data") val certificateData: CertificateData?, + @SerializedName("completion_summary") val completionSummary: CompletionSummary?, + @SerializedName("course_grade") val courseGrade: CourseGrade?, + @SerializedName("credit_course_requirements") val creditCourseRequirements: String?, + @SerializedName("end") val end: String?, + @SerializedName("enrollment_mode") val enrollmentMode: String?, + @SerializedName("grading_policy") val gradingPolicy: GradingPolicy?, + @SerializedName("has_scheduled_content") val hasScheduledContent: Boolean?, + @SerializedName("section_scores") val sectionScores: List?, + @SerializedName("studio_url") val studioUrl: String?, + @SerializedName("username") val username: String?, + @SerializedName("user_has_passing_grade") val userHasPassingGrade: Boolean?, + @SerializedName("verification_data") val verificationData: VerificationData?, + @SerializedName("disable_progress_graph") val disableProgressGraph: Boolean?, +) { + data class CertificateData( + @SerializedName("cert_status") val certStatus: String?, + @SerializedName("cert_web_view_url") val certWebViewUrl: String?, + @SerializedName("download_url") val downloadUrl: String?, + @SerializedName("certificate_available_date") val certificateAvailableDate: String? + ) { + fun mapToRoomEntity() = CertificateDataDb( + certStatus = certStatus.orEmpty(), + certWebViewUrl = certWebViewUrl.orEmpty(), + downloadUrl = downloadUrl.orEmpty(), + certificateAvailableDate = certificateAvailableDate.orEmpty() + ) + + fun mapToDomain() = CourseProgress.CertificateData( + certStatus = certStatus ?: "", + certWebViewUrl = certWebViewUrl ?: "", + downloadUrl = downloadUrl ?: "", + certificateAvailableDate = certificateAvailableDate ?: "" + ) + } + + data class CompletionSummary( + @SerializedName("complete_count") val completeCount: Int?, + @SerializedName("incomplete_count") val incompleteCount: Int?, + @SerializedName("locked_count") val lockedCount: Int? + ) { + fun mapToRoomEntity() = CompletionSummaryDb( + completeCount = completeCount ?: 0, + incompleteCount = incompleteCount ?: 0, + lockedCount = lockedCount ?: 0 + ) + + fun mapToDomain() = CourseProgress.CompletionSummary( + completeCount = completeCount ?: 0, + incompleteCount = incompleteCount ?: 0, + lockedCount = lockedCount ?: 0 + ) + } + + data class CourseGrade( + @SerializedName("letter_grade") val letterGrade: String?, + @SerializedName("percent") val percent: Double?, + @SerializedName("is_passing") val isPassing: Boolean? + ) { + fun mapToRoomEntity() = CourseGradeDb( + letterGrade = letterGrade.orEmpty(), + percent = percent ?: 0.0, + isPassing = isPassing ?: false + ) + + fun mapToDomain() = CourseProgress.CourseGrade( + letterGrade = letterGrade ?: "", + percent = percent ?: 0.0, + isPassing = isPassing ?: false + ) + } + + data class GradingPolicy( + @SerializedName("assignment_policies") val assignmentPolicies: List?, + @SerializedName("grade_range") val gradeRange: Map?, + @SerializedName("assignment_colors") val assignmentColors: List? + ) { + // TODO Temporary solution. Backend will returns color list later + val defaultColors = listOf( + "#D24242", + "#7B9645", + "#5A5AD8", + "#B0842C", + "#2E90C2", + "#D13F88", + "#36A17D", + "#AE5AD8", + "#3BA03B" + ) + + fun mapToRoomEntity() = GradingPolicyDb( + assignmentPolicies = assignmentPolicies?.map { it.mapToRoomEntity() } ?: emptyList(), + gradeRange = gradeRange ?: emptyMap(), + assignmentColors = assignmentColors ?: defaultColors + ) + + fun mapToDomain() = CourseProgress.GradingPolicy( + assignmentPolicies = assignmentPolicies?.map { it.mapToDomain() } ?: emptyList(), + gradeRange = gradeRange ?: emptyMap(), + assignmentColors = assignmentColors?.map { colorString -> + Color(colorString.toColorInt()) + } ?: defaultColors.map { Color(it.toColorInt()) } + ) + + data class AssignmentPolicy( + @SerializedName("num_droppable") val numDroppable: Int?, + @SerializedName("num_total") val numTotal: Int?, + @SerializedName("short_label") val shortLabel: String?, + @SerializedName("type") val type: String?, + @SerializedName("weight") val weight: Double? + ) { + fun mapToRoomEntity() = GradingPolicyDb.AssignmentPolicyDb( + numDroppable = numDroppable ?: 0, + numTotal = numTotal ?: 0, + shortLabel = shortLabel.orEmpty(), + type = type.orEmpty(), + weight = weight ?: 0.0 + ) + + fun mapToDomain() = CourseProgress.GradingPolicy.AssignmentPolicy( + numDroppable = numDroppable ?: 0, + numTotal = numTotal ?: 0, + shortLabel = shortLabel ?: "", + type = type ?: "", + weight = weight ?: 0.0 + ) + } + } + + data class SectionScore( + @SerializedName("display_name") val displayName: String?, + @SerializedName("subsections") val subsections: List? + ) { + fun mapToRoomEntity() = SectionScoreDb( + displayName = displayName.orEmpty(), + subsections = subsections?.map { it.mapToRoomEntity() } ?: emptyList() + ) + + fun mapToDomain() = CourseProgress.SectionScore( + displayName = displayName ?: "", + subsections = subsections?.map { it.mapToDomain() } ?: emptyList() + ) + data class Subsection( + @SerializedName("assignment_type") val assignmentType: String?, + @SerializedName("block_key") val blockKey: String?, + @SerializedName("display_name") val displayName: String?, + @SerializedName("has_graded_assignment") val hasGradedAssignment: Boolean?, + @SerializedName("override") val override: String?, + @SerializedName("learner_has_access") val learnerHasAccess: Boolean?, + @SerializedName("num_points_earned") val numPointsEarned: Float?, + @SerializedName("num_points_possible") val numPointsPossible: Float?, + @SerializedName("percent_graded") val percentGraded: Double?, + @SerializedName("problem_scores") val problemScores: List?, + @SerializedName("show_correctness") val showCorrectness: String?, + @SerializedName("show_grades") val showGrades: Boolean?, + @SerializedName("url") val url: String? + ) { + fun mapToRoomEntity() = SectionScoreDb.SubsectionDb( + assignmentType = assignmentType.orEmpty(), + blockKey = blockKey.orEmpty(), + displayName = displayName.orEmpty(), + hasGradedAssignment = hasGradedAssignment ?: false, + override = override.orEmpty(), + learnerHasAccess = learnerHasAccess ?: false, + numPointsEarned = numPointsEarned ?: 0f, + numPointsPossible = numPointsPossible ?: 0f, + percentGraded = percentGraded ?: 0.0, + problemScores = problemScores?.map { it.mapToRoomEntity() } ?: emptyList(), + showCorrectness = showCorrectness.orEmpty(), + showGrades = showGrades ?: false, + url = url.orEmpty() + ) + + fun mapToDomain() = CourseProgress.SectionScore.Subsection( + assignmentType = assignmentType ?: "", + blockKey = blockKey ?: "", + displayName = displayName ?: "", + hasGradedAssignment = hasGradedAssignment ?: false, + override = override ?: "", + learnerHasAccess = learnerHasAccess ?: false, + numPointsEarned = numPointsEarned ?: 0f, + numPointsPossible = numPointsPossible ?: 0f, + percentGraded = percentGraded ?: 0.0, + problemScores = problemScores?.map { it.mapToDomain() } ?: emptyList(), + showCorrectness = showCorrectness ?: "", + showGrades = showGrades ?: false, + url = url ?: "" + ) + data class ProblemScore( + @SerializedName("earned") val earned: Double?, + @SerializedName("possible") val possible: Double? + ) { + fun mapToRoomEntity() = SectionScoreDb.SubsectionDb.ProblemScoreDb( + earned = earned ?: 0.0, + possible = possible ?: 0.0 + ) + + fun mapToDomain() = CourseProgress.SectionScore.Subsection.ProblemScore( + earned = earned ?: 0.0, + possible = possible ?: 0.0 + ) + } + } + } + + data class VerificationData( + @SerializedName("link") val link: String?, + @SerializedName("status") val status: String?, + @SerializedName("status_date") val statusDate: String? + ) { + fun mapToRoomEntity() = VerificationDataDb( + link = link.orEmpty(), + status = status.orEmpty(), + statusDate = statusDate.orEmpty() + ) + + fun mapToDomain() = CourseProgress.VerificationData( + link = link ?: "", + status = status ?: "", + statusDate = statusDate ?: "" + ) + } + + fun mapToDomain(): CourseProgress { + return CourseProgress( + verifiedMode = verifiedMode ?: "", + accessExpiration = accessExpiration ?: "", + certificateData = certificateData?.mapToDomain(), + completionSummary = completionSummary?.mapToDomain(), + courseGrade = courseGrade?.mapToDomain(), + creditCourseRequirements = creditCourseRequirements ?: "", + end = end ?: "", + enrollmentMode = enrollmentMode ?: "", + gradingPolicy = gradingPolicy?.mapToDomain(), + hasScheduledContent = hasScheduledContent ?: false, + sectionScores = sectionScores?.map { it.mapToDomain() } ?: emptyList(), + studioUrl = studioUrl ?: "", + username = username ?: "", + userHasPassingGrade = userHasPassingGrade ?: false, + verificationData = verificationData?.mapToDomain(), + disableProgressGraph = disableProgressGraph ?: false, + ) + } + + fun mapToRoomEntity(courseId: String): CourseProgressEntity { + return CourseProgressEntity( + courseId = courseId, + verifiedMode = verifiedMode.orEmpty(), + accessExpiration = accessExpiration.orEmpty(), + certificateData = certificateData?.mapToRoomEntity(), + completionSummary = completionSummary?.mapToRoomEntity(), + courseGrade = courseGrade?.mapToRoomEntity(), + creditCourseRequirements = creditCourseRequirements.orEmpty(), + end = end.orEmpty(), + enrollmentMode = enrollmentMode.orEmpty(), + gradingPolicy = gradingPolicy?.mapToRoomEntity(), + hasScheduledContent = hasScheduledContent ?: false, + sectionScores = sectionScores?.map { it.mapToRoomEntity() } ?: emptyList(), + studioUrl = studioUrl.orEmpty(), + username = username.orEmpty(), + userHasPassingGrade = userHasPassingGrade ?: false, + verificationData = verificationData?.mapToRoomEntity(), + disableProgressGraph = disableProgressGraph ?: false, + ) + } +} diff --git a/core/src/main/java/org/openedx/core/data/model/room/CourseProgressEntity.kt b/core/src/main/java/org/openedx/core/data/model/room/CourseProgressEntity.kt new file mode 100644 index 000000000..6c98cbed2 --- /dev/null +++ b/core/src/main/java/org/openedx/core/data/model/room/CourseProgressEntity.kt @@ -0,0 +1,236 @@ +package org.openedx.core.data.model.room + +import androidx.compose.ui.graphics.Color +import androidx.core.graphics.toColorInt +import androidx.room.ColumnInfo +import androidx.room.Embedded +import androidx.room.Entity +import androidx.room.PrimaryKey +import org.openedx.core.domain.model.CourseProgress + +@Entity(tableName = "course_progress_table") +data class CourseProgressEntity( + @PrimaryKey + @ColumnInfo("courseId") + val courseId: String, + @ColumnInfo("verifiedMode") + val verifiedMode: String, + @ColumnInfo("accessExpiration") + val accessExpiration: String, + @Embedded(prefix = "certificate_") + val certificateData: CertificateDataDb?, + @Embedded(prefix = "completion_") + val completionSummary: CompletionSummaryDb?, + @Embedded(prefix = "grade_") + val courseGrade: CourseGradeDb?, + @ColumnInfo("creditCourseRequirements") + val creditCourseRequirements: String, + @ColumnInfo("end") + val end: String, + @ColumnInfo("enrollmentMode") + val enrollmentMode: String, + @Embedded(prefix = "grading_") + val gradingPolicy: GradingPolicyDb?, + @ColumnInfo("hasScheduledContent") + val hasScheduledContent: Boolean, + @ColumnInfo("sectionScores") + val sectionScores: List, + @ColumnInfo("studioUrl") + val studioUrl: String, + @ColumnInfo("username") + val username: String, + @ColumnInfo("userHasPassingGrade") + val userHasPassingGrade: Boolean, + @Embedded(prefix = "verification_") + val verificationData: VerificationDataDb?, + @ColumnInfo("disableProgressGraph") + val disableProgressGraph: Boolean, +) { + fun mapToDomain(): CourseProgress { + return CourseProgress( + verifiedMode = verifiedMode, + accessExpiration = accessExpiration, + certificateData = certificateData?.mapToDomain(), + completionSummary = completionSummary?.mapToDomain(), + courseGrade = courseGrade?.mapToDomain(), + creditCourseRequirements = creditCourseRequirements, + end = end, + enrollmentMode = enrollmentMode, + gradingPolicy = gradingPolicy?.mapToDomain(), + hasScheduledContent = hasScheduledContent, + sectionScores = sectionScores.map { it.mapToDomain() }, + studioUrl = studioUrl, + username = username, + userHasPassingGrade = userHasPassingGrade, + verificationData = verificationData?.mapToDomain(), + disableProgressGraph = disableProgressGraph, + ) + } +} + +data class CertificateDataDb( + @ColumnInfo("certStatus") + val certStatus: String, + @ColumnInfo("certWebViewUrl") + val certWebViewUrl: String, + @ColumnInfo("downloadUrl") + val downloadUrl: String, + @ColumnInfo("certificateAvailableDate") + val certificateAvailableDate: String +) { + fun mapToDomain() = CourseProgress.CertificateData( + certStatus = certStatus, + certWebViewUrl = certWebViewUrl, + downloadUrl = downloadUrl, + certificateAvailableDate = certificateAvailableDate + ) +} + +data class CompletionSummaryDb( + @ColumnInfo("completeCount") + val completeCount: Int, + @ColumnInfo("incompleteCount") + val incompleteCount: Int, + @ColumnInfo("lockedCount") + val lockedCount: Int +) { + fun mapToDomain() = CourseProgress.CompletionSummary( + completeCount = completeCount, + incompleteCount = incompleteCount, + lockedCount = lockedCount + ) +} + +data class CourseGradeDb( + @ColumnInfo("letterGrade") + val letterGrade: String, + @ColumnInfo("percent") + val percent: Double, + @ColumnInfo("isPassing") + val isPassing: Boolean +) { + fun mapToDomain() = CourseProgress.CourseGrade( + letterGrade = letterGrade, + percent = percent, + isPassing = isPassing + ) +} + +data class GradingPolicyDb( + @ColumnInfo("assignmentPolicies") + val assignmentPolicies: List, + @ColumnInfo("gradeRange") + val gradeRange: Map, + @ColumnInfo("assignmentColors") + val assignmentColors: List +) { + fun mapToDomain() = CourseProgress.GradingPolicy( + assignmentPolicies = assignmentPolicies.map { it.mapToDomain() }, + gradeRange = gradeRange, + assignmentColors = assignmentColors.map { colorString -> + Color(colorString.toColorInt()) + } + ) + data class AssignmentPolicyDb( + @ColumnInfo("numDroppable") + val numDroppable: Int, + @ColumnInfo("numTotal") + val numTotal: Int, + @ColumnInfo("shortLabel") + val shortLabel: String, + @ColumnInfo("type") + val type: String, + @ColumnInfo("weight") + val weight: Double + ) { + fun mapToDomain() = CourseProgress.GradingPolicy.AssignmentPolicy( + numDroppable = numDroppable, + numTotal = numTotal, + shortLabel = shortLabel, + type = type, + weight = weight + ) + } +} + +data class SectionScoreDb( + @ColumnInfo("displayName") + val displayName: String, + @ColumnInfo("subsections") + val subsections: List +) { + fun mapToDomain() = CourseProgress.SectionScore( + displayName = displayName, + subsections = subsections.map { it.mapToDomain() } + ) + data class SubsectionDb( + @ColumnInfo("assignmentType") + val assignmentType: String, + @ColumnInfo("blockKey") + val blockKey: String, + @ColumnInfo("displayName") + val displayName: String, + @ColumnInfo("hasGradedAssignment") + val hasGradedAssignment: Boolean, + @ColumnInfo("override") + val override: String, + @ColumnInfo("learnerHasAccess") + val learnerHasAccess: Boolean, + @ColumnInfo("numPointsEarned") + val numPointsEarned: Float, + @ColumnInfo("numPointsPossible") + val numPointsPossible: Float, + @ColumnInfo("percentGraded") + val percentGraded: Double, + @ColumnInfo("problemScores") + val problemScores: List, + @ColumnInfo("showCorrectness") + val showCorrectness: String, + @ColumnInfo("showGrades") + val showGrades: Boolean, + @ColumnInfo("url") + val url: String + ) { + fun mapToDomain() = CourseProgress.SectionScore.Subsection( + assignmentType = assignmentType, + blockKey = blockKey, + displayName = displayName, + hasGradedAssignment = hasGradedAssignment, + override = override, + learnerHasAccess = learnerHasAccess, + numPointsEarned = numPointsEarned, + numPointsPossible = numPointsPossible, + percentGraded = percentGraded, + problemScores = problemScores.map { it.mapToDomain() }, + showCorrectness = showCorrectness, + showGrades = showGrades, + url = url + ) + data class ProblemScoreDb( + @ColumnInfo("earned") + val earned: Double, + @ColumnInfo("possible") + val possible: Double + ) { + fun mapToDomain() = CourseProgress.SectionScore.Subsection.ProblemScore( + earned = earned, + possible = possible + ) + } + } +} + +data class VerificationDataDb( + @ColumnInfo("link") + val link: String, + @ColumnInfo("status") + val status: String, + @ColumnInfo("statusDate") + val statusDate: String +) { + fun mapToDomain() = CourseProgress.VerificationData( + link = link, + status = status, + statusDate = statusDate + ) +} diff --git a/core/src/main/java/org/openedx/core/data/storage/CourseDao.kt b/core/src/main/java/org/openedx/core/data/storage/CourseDao.kt index 1ce813242..14ac6713a 100644 --- a/core/src/main/java/org/openedx/core/data/storage/CourseDao.kt +++ b/core/src/main/java/org/openedx/core/data/storage/CourseDao.kt @@ -4,7 +4,9 @@ import androidx.room.Dao import androidx.room.Insert import androidx.room.OnConflictStrategy import androidx.room.Query +import androidx.room.Transaction import org.openedx.core.data.model.room.CourseEnrollmentDetailsEntity +import org.openedx.core.data.model.room.CourseProgressEntity import org.openedx.core.data.model.room.CourseStructureEntity @Dao @@ -16,8 +18,21 @@ interface CourseDao { @Insert(onConflict = OnConflictStrategy.REPLACE) suspend fun insertCourseStructureEntity(vararg courseStructureEntity: CourseStructureEntity) + @Transaction + suspend fun clearCourseData() { + clearCourseStructureData() + clearCourseProgressData() + clearEnrollmentCachedData() + } + @Query("DELETE FROM course_structure_table") - suspend fun clearCachedData() + suspend fun clearCourseStructureData() + + @Query("DELETE FROM course_progress_table") + suspend fun clearCourseProgressData() + + @Query("DELETE FROM course_enrollment_details_table") + suspend fun clearEnrollmentCachedData() @Insert(onConflict = OnConflictStrategy.REPLACE) suspend fun insertCourseEnrollmentDetailsEntity(vararg courseEnrollmentDetailsEntity: CourseEnrollmentDetailsEntity) @@ -25,6 +40,9 @@ interface CourseDao { @Query("SELECT * FROM course_enrollment_details_table WHERE id=:id") suspend fun getCourseEnrollmentDetailsById(id: String): CourseEnrollmentDetailsEntity? - @Query("DELETE FROM course_enrollment_details_table") - suspend fun clearEnrollmentCachedData() + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun insertCourseProgressEntity(vararg courseProgressEntity: CourseProgressEntity) + + @Query("SELECT * FROM course_progress_table WHERE courseId=:id") + suspend fun getCourseProgressById(id: String): CourseProgressEntity? } diff --git a/core/src/main/java/org/openedx/core/domain/model/CourseProgress.kt b/core/src/main/java/org/openedx/core/domain/model/CourseProgress.kt new file mode 100644 index 000000000..537959ece --- /dev/null +++ b/core/src/main/java/org/openedx/core/domain/model/CourseProgress.kt @@ -0,0 +1,135 @@ +package org.openedx.core.domain.model + +import androidx.compose.ui.graphics.Color + +data class CourseProgress( + val verifiedMode: String, + val accessExpiration: String, + val certificateData: CertificateData?, + val completionSummary: CompletionSummary?, + val courseGrade: CourseGrade?, + val creditCourseRequirements: String, + val end: String, + val enrollmentMode: String, + val gradingPolicy: GradingPolicy?, + val hasScheduledContent: Boolean, + val sectionScores: List, + val studioUrl: String, + val username: String, + val userHasPassingGrade: Boolean, + val verificationData: VerificationData?, + val disableProgressGraph: Boolean, +) { + val completion = with(completionSummary) { + val total = (this?.completeCount ?: 0) + (this?.incompleteCount ?: 0) + if (total > 0f) (this?.completeCount ?: 0).toFloat() / total else 0f + } + val completionPercent = (completion * 100f).toInt() + val requiredGrade = gradingPolicy?.gradeRange?.values?.firstOrNull() ?: 0f + val requiredGradePercent = (requiredGrade * 100f).toInt() + + fun getEarnedAssignmentProblems( + policy: GradingPolicy.AssignmentPolicy + ) = sectionScores + .flatMap { section -> + section.subsections.filter { it.assignmentType == policy.type } + }.sumOf { subsection -> + subsection.problemScores.sumOf { it.earned } + } + + fun getPossibleAssignmentProblems( + policy: GradingPolicy.AssignmentPolicy + ) = sectionScores + .flatMap { section -> + section.subsections.filter { it.assignmentType == policy.type } + }.sumOf { subsection -> + subsection.problemScores.sumOf { it.possible } + } + + fun getAssignmentGradedPercent(type: String): Float { + val assignmentSections = sectionScores + .flatMap { it.subsections } + .filter { it.assignmentType == type } + if (assignmentSections.isEmpty()) return 0f + return assignmentSections.sumOf { it.percentGraded }.toFloat() / assignmentSections.size + } + + fun getAssignmentWeightedGradedPercent(assignmentPolicy: GradingPolicy.AssignmentPolicy): Float { + return (assignmentPolicy.weight * getAssignmentGradedPercent(assignmentPolicy.type) * 100f).toFloat() + } + + fun getTotalWeightPercent() = + gradingPolicy?.assignmentPolicies?.sumOf { getAssignmentWeightedGradedPercent(it).toDouble() } + ?.toFloat() ?: 0f + + fun getNotCompletedWeightedGradePercent(): Float { + val totalWeightedPercent = getTotalWeightPercent() + val notCompletedPercent = 100.0 - totalWeightedPercent + return if (notCompletedPercent < 0.0) 0f else notCompletedPercent.toFloat() + } + + data class CertificateData( + val certStatus: String, + val certWebViewUrl: String, + val downloadUrl: String, + val certificateAvailableDate: String + ) + + data class CompletionSummary( + val completeCount: Int, + val incompleteCount: Int, + val lockedCount: Int + ) + + data class CourseGrade( + val letterGrade: String, + val percent: Double, + val isPassing: Boolean + ) + + data class GradingPolicy( + val assignmentPolicies: List, + val gradeRange: Map, + val assignmentColors: List, + ) { + data class AssignmentPolicy( + val numDroppable: Int, + val numTotal: Int, + val shortLabel: String, + val type: String, + val weight: Double + ) + } + + data class SectionScore( + val displayName: String, + val subsections: List + ) { + data class Subsection( + val assignmentType: String, + val blockKey: String, + val displayName: String, + val hasGradedAssignment: Boolean, + val override: String, + val learnerHasAccess: Boolean, + val numPointsEarned: Float, + val numPointsPossible: Float, + val percentGraded: Double, + val problemScores: List, + val showCorrectness: String, + val showGrades: Boolean, + val url: String + ) { + data class ProblemScore( + val earned: Double, + val possible: Double + ) + } + } + + data class VerificationData( + val link: String, + val status: String, + val statusDate: String + ) +} diff --git a/core/src/main/java/org/openedx/core/system/notifier/CourseNotifier.kt b/core/src/main/java/org/openedx/core/system/notifier/CourseNotifier.kt index be653a3ed..d3dac7d42 100644 --- a/core/src/main/java/org/openedx/core/system/notifier/CourseNotifier.kt +++ b/core/src/main/java/org/openedx/core/system/notifier/CourseNotifier.kt @@ -22,4 +22,5 @@ class CourseNotifier { suspend fun send(event: CourseOpenBlock) = channel.emit(event) suspend fun send(event: RefreshDates) = channel.emit(event) suspend fun send(event: RefreshDiscussions) = channel.emit(event) + suspend fun send(event: RefreshProgress) = channel.emit(event) } diff --git a/core/src/main/java/org/openedx/core/system/notifier/RefreshProgress.kt b/core/src/main/java/org/openedx/core/system/notifier/RefreshProgress.kt new file mode 100644 index 000000000..c0835f787 --- /dev/null +++ b/core/src/main/java/org/openedx/core/system/notifier/RefreshProgress.kt @@ -0,0 +1,3 @@ +package org.openedx.core.system.notifier + +object RefreshProgress : CourseEvent diff --git a/core/src/main/java/org/openedx/core/ui/theme/AppColors.kt b/core/src/main/java/org/openedx/core/ui/theme/AppColors.kt index 12da2cfce..143bfabf7 100644 --- a/core/src/main/java/org/openedx/core/ui/theme/AppColors.kt +++ b/core/src/main/java/org/openedx/core/ui/theme/AppColors.kt @@ -78,7 +78,8 @@ data class AppColors( val settingsTitleContent: Color, val progressBarColor: Color, - val progressBarBackgroundColor: Color + val progressBarBackgroundColor: Color, + val gradeProgressBarBorder: Color, ) { val primary: Color get() = material.primary val primaryVariant: Color get() = material.primaryVariant diff --git a/core/src/main/java/org/openedx/core/ui/theme/Theme.kt b/core/src/main/java/org/openedx/core/ui/theme/Theme.kt index 2ad2a4eae..c4f54ac17 100644 --- a/core/src/main/java/org/openedx/core/ui/theme/Theme.kt +++ b/core/src/main/java/org/openedx/core/ui/theme/Theme.kt @@ -96,7 +96,8 @@ private val DarkColorPalette = AppColors( settingsTitleContent = dark_settings_title_content, progressBarColor = dark_progress_bar_color, - progressBarBackgroundColor = dark_progress_bar_background_color + progressBarBackgroundColor = dark_progress_bar_background_color, + gradeProgressBarBorder = dark_grade_progress_bar_color ) private val LightColorPalette = AppColors( @@ -185,7 +186,8 @@ private val LightColorPalette = AppColors( settingsTitleContent = light_settings_title_content, progressBarColor = light_progress_bar_color, - progressBarBackgroundColor = light_progress_bar_background_color + progressBarBackgroundColor = light_progress_bar_background_color, + gradeProgressBarBorder = light_grade_progress_bar_color ) val MaterialTheme.appColors: AppColors diff --git a/core/src/main/res/values/strings.xml b/core/src/main/res/values/strings.xml index 5dd32864c..e28580acc 100644 --- a/core/src/main/res/values/strings.xml +++ b/core/src/main/res/values/strings.xml @@ -158,6 +158,7 @@ No course content is currently available. There are currently no videos for this course. Course dates are currently not available. + This course does not contain exams or graded assignments. Unable to load discussions.\n Please try again later. There are currently no handouts for this course. There are currently no announcements for this course. diff --git a/core/src/openedx/org/openedx/core/ui/theme/Colors.kt b/core/src/openedx/org/openedx/core/ui/theme/Colors.kt index d2618e6b0..65c082f70 100644 --- a/core/src/openedx/org/openedx/core/ui/theme/Colors.kt +++ b/core/src/openedx/org/openedx/core/ui/theme/Colors.kt @@ -74,6 +74,7 @@ val light_course_home_back_btn_background = Color.White val light_settings_title_content = Color.White val light_progress_bar_color = light_primary val light_progress_bar_background_color = Color(0xFFCCD4E0) +val light_grade_progress_bar_color = Color.Black val dark_primary = Color(0xFF3F68F8) val dark_primary_variant = Color(0xFF3700B3) @@ -147,3 +148,4 @@ val dark_course_home_back_btn_background = Color.Black val dark_settings_title_content = Color.White val dark_progress_bar_color = light_primary val dark_progress_bar_background_color = Color(0xFF8E9BAE) +val dark_grade_progress_bar_color = Color.Transparent diff --git a/course/src/main/java/org/openedx/course/data/repository/CourseRepository.kt b/course/src/main/java/org/openedx/course/data/repository/CourseRepository.kt index bf39cc80c..914ce7191 100644 --- a/course/src/main/java/org/openedx/course/data/repository/CourseRepository.kt +++ b/course/src/main/java/org/openedx/course/data/repository/CourseRepository.kt @@ -14,6 +14,7 @@ import org.openedx.core.domain.model.CourseComponentStatus import org.openedx.core.domain.model.CourseDatesBannerInfo import org.openedx.core.domain.model.CourseDatesResult import org.openedx.core.domain.model.CourseEnrollmentDetails +import org.openedx.core.domain.model.CourseProgress import org.openedx.core.domain.model.CourseStructure import org.openedx.core.exception.NoCachedDataException import org.openedx.core.extension.channelFlowWithAwait @@ -45,7 +46,10 @@ class CourseRepository( suspend fun getAllDownloadModels() = downloadDao.readAllData().map { it.mapToDomain() } - suspend fun getCourseStructureFlow(courseId: String, forceRefresh: Boolean = true): Flow = + suspend fun getCourseStructureFlow( + courseId: String, + forceRefresh: Boolean = true + ): Flow = channelFlowWithAwait { var hasCourseStructure = false val cachedCourseStructure = courseStructure[courseId] ?: ( @@ -235,4 +239,17 @@ class CourseRepository( downloadDao.removeOfflineXBlockProgress(listOf(blockId)) } } + + fun getCourseProgress(courseId: String, isRefresh: Boolean): Flow = + channelFlowWithAwait { + if (!isRefresh) { + val cached = courseDao.getCourseProgressById(courseId) + if (cached != null) { + trySend(cached.mapToDomain()) + } + } + val response = api.getCourseProgress(courseId) + courseDao.insertCourseProgressEntity(response.mapToRoomEntity(courseId)) + trySend(response.mapToDomain()) + } } diff --git a/course/src/main/java/org/openedx/course/data/storage/CourseConverter.kt b/course/src/main/java/org/openedx/course/data/storage/CourseConverter.kt index 8daa7fb13..c59b69638 100644 --- a/course/src/main/java/org/openedx/course/data/storage/CourseConverter.kt +++ b/course/src/main/java/org/openedx/course/data/storage/CourseConverter.kt @@ -1,27 +1,16 @@ package org.openedx.course.data.storage import androidx.room.TypeConverter +import com.google.common.reflect.TypeToken import com.google.gson.Gson import org.openedx.core.data.model.room.BlockDb -import org.openedx.core.data.model.room.VideoInfoDb +import org.openedx.core.data.model.room.GradingPolicyDb +import org.openedx.core.data.model.room.SectionScoreDb import org.openedx.core.data.model.room.discovery.CourseDateBlockDb import org.openedx.foundation.extension.genericType class CourseConverter { - @TypeConverter - fun fromVideoDb(value: VideoInfoDb?): String { - if (value == null) return "" - val json = Gson().toJson(value) - return json.toString() - } - - @TypeConverter - fun toVideoDb(value: String): VideoInfoDb? { - if (value.isEmpty()) return null - return Gson().fromJson(value, VideoInfoDb::class.java) - } - @TypeConverter fun fromListOfString(value: List): String { val json = Gson().toJson(value) @@ -46,18 +35,6 @@ class CourseConverter { return Gson().fromJson(value, type) } - @TypeConverter - fun fromStringToMap(value: String?): Map { - val mapType = genericType>() - return Gson().fromJson(value, mapType) - } - - @TypeConverter - fun fromMapToString(map: Map): String { - val gson = Gson() - return gson.toJson(map) - } - @TypeConverter fun fromListOfCourseDateBlockDb(value: List): String { val json = Gson().toJson(value) @@ -69,4 +46,31 @@ class CourseConverter { val type = genericType>() return Gson().fromJson(value, type) } + + @TypeConverter + fun fromSectionScoreDbList(value: List?): String = + Gson().toJson(value) + + @TypeConverter + fun toSectionScoreDbList(value: String): List = + Gson().fromJson(value, object : TypeToken>() {}.type) + + @TypeConverter + fun fromAssignmentPolicyDbList(value: List?): String = + Gson().toJson(value) + + @TypeConverter + fun toAssignmentPolicyDbList(value: String): List = + Gson().fromJson( + value, + object : TypeToken>() {}.type + ) + + @TypeConverter + fun fromGradeRangeMap(value: Map?): String = + Gson().toJson(value) + + @TypeConverter + fun toGradeRangeMap(value: String): Map = + Gson().fromJson(value, object : TypeToken>() {}.type) } diff --git a/course/src/main/java/org/openedx/course/domain/interactor/CourseInteractor.kt b/course/src/main/java/org/openedx/course/domain/interactor/CourseInteractor.kt index 8fab7bba7..49fdf0d42 100644 --- a/course/src/main/java/org/openedx/course/domain/interactor/CourseInteractor.kt +++ b/course/src/main/java/org/openedx/course/domain/interactor/CourseInteractor.kt @@ -65,14 +65,19 @@ class CourseInteractor( return blocks.firstOrNull { it.descendants.contains(childId) } } - private fun addToResultBlocks(videoBlock: Block, verticalBlock: Block, resultBlocks: MutableList) { + private fun addToResultBlocks( + videoBlock: Block, + verticalBlock: Block, + resultBlocks: MutableList + ) { resultBlocks.add(videoBlock) val verticalIndex = resultBlocks.indexOfFirst { it.id == verticalBlock.id } if (verticalIndex == -1) { resultBlocks.add(verticalBlock.copy(descendants = listOf(videoBlock.id))) } else { val block = resultBlocks[verticalIndex] - resultBlocks[verticalIndex] = block.copy(descendants = block.descendants + videoBlock.id) + resultBlocks[verticalIndex] = + block.copy(descendants = block.descendants + videoBlock.id) } } @@ -114,4 +119,7 @@ class CourseInteractor( suspend fun submitOfflineXBlockProgress(blockId: String, courseId: String) = repository.submitOfflineXBlockProgress(blockId, courseId) + + fun getCourseProgress(courseId: String, isRefresh: Boolean) = + repository.getCourseProgress(courseId, isRefresh) } diff --git a/course/src/main/java/org/openedx/course/presentation/CourseAnalytics.kt b/course/src/main/java/org/openedx/course/presentation/CourseAnalytics.kt index 0dbe660e5..0eff40583 100644 --- a/course/src/main/java/org/openedx/course/presentation/CourseAnalytics.kt +++ b/course/src/main/java/org/openedx/course/presentation/CourseAnalytics.kt @@ -66,6 +66,10 @@ enum class CourseAnalyticsEvent(val eventName: String, val biValue: String) { "Course:Handouts Tab", "edx.bi.app.course.handouts_tab" ), + PROGRESS_TAB( + "Course:Progress Tab", + "edx.bi.app.course.progress_tab" + ), ANNOUNCEMENTS( "Course:Announcements", "edx.bi.app.course.announcements" diff --git a/course/src/main/java/org/openedx/course/presentation/container/CourseContainerFragment.kt b/course/src/main/java/org/openedx/course/presentation/container/CourseContainerFragment.kt index 1abd8cbb2..a71d954df 100644 --- a/course/src/main/java/org/openedx/course/presentation/container/CourseContainerFragment.kt +++ b/course/src/main/java/org/openedx/course/presentation/container/CourseContainerFragment.kt @@ -88,6 +88,7 @@ import org.openedx.course.presentation.handouts.HandoutsScreen import org.openedx.course.presentation.handouts.HandoutsType import org.openedx.course.presentation.offline.CourseOfflineScreen import org.openedx.course.presentation.outline.CourseOutlineScreen +import org.openedx.course.presentation.progress.CourseProgressScreen import org.openedx.course.presentation.ui.CourseVideosScreen import org.openedx.course.presentation.ui.DatesShiftedSnackBar import org.openedx.discussion.presentation.topics.DiscussionTopicsScreen @@ -267,6 +268,7 @@ fun CourseDashboard( CourseContainerTab.VIDEOS.name -> CourseContainerTab.VIDEOS CourseContainerTab.DATES.name -> CourseContainerTab.DATES CourseContainerTab.DISCUSSIONS.name -> CourseContainerTab.DISCUSSIONS + CourseContainerTab.PROGRESS.name -> CourseContainerTab.PROGRESS CourseContainerTab.MORE.name -> CourseContainerTab.MORE else -> CourseContainerTab.HOME } @@ -344,8 +346,7 @@ fun CourseDashboard( when (accessStatus.value) { CourseAccessError.AUDIT_EXPIRED_NOT_UPGRADABLE, CourseAccessError.NOT_YET_STARTED, - CourseAccessError.UNKNOWN, - -> { + CourseAccessError.UNKNOWN -> { CourseAccessErrorView( viewModel = viewModel, accessError = accessStatus.value, @@ -492,6 +493,13 @@ private fun DashboardPager( ) } + CourseContainerTab.PROGRESS -> { + CourseProgressScreen( + windowSize = windowSize, + viewModel = koinViewModel(parameters = { parametersOf(viewModel.courseId) }), + ) + } + CourseContainerTab.MORE -> { HandoutsScreen( windowSize = windowSize, @@ -608,8 +616,7 @@ private fun SetupCourseAccessErrorButtons( ) { when (accessError) { CourseAccessError.AUDIT_EXPIRED_NOT_UPGRADABLE, - CourseAccessError.NOT_YET_STARTED, - -> { + CourseAccessError.NOT_YET_STARTED -> { OpenEdXButton( text = stringResource(R.string.course_label_back), onClick = { fragmentManager.popBackStack() }, diff --git a/course/src/main/java/org/openedx/course/presentation/container/CourseContainerTab.kt b/course/src/main/java/org/openedx/course/presentation/container/CourseContainerTab.kt index b591c7ecf..236c548f6 100644 --- a/course/src/main/java/org/openedx/course/presentation/container/CourseContainerTab.kt +++ b/course/src/main/java/org/openedx/course/presentation/container/CourseContainerTab.kt @@ -6,6 +6,7 @@ import androidx.compose.material.icons.automirrored.filled.Chat import androidx.compose.material.icons.automirrored.filled.TextSnippet import androidx.compose.material.icons.filled.CloudDownload import androidx.compose.material.icons.filled.Home +import androidx.compose.material.icons.filled.Moving import androidx.compose.material.icons.outlined.CalendarMonth import androidx.compose.material.icons.rounded.PlayCircleFilled import androidx.compose.ui.graphics.vector.ImageVector @@ -19,8 +20,9 @@ enum class CourseContainerTab( ) : TabItem { HOME(R.string.course_container_nav_home, Icons.Default.Home), VIDEOS(R.string.course_container_nav_videos, Icons.Rounded.PlayCircleFilled), + PROGRESS(R.string.course_container_nav_progress, Icons.Default.Moving), DATES(R.string.course_container_nav_dates, Icons.Outlined.CalendarMonth), OFFLINE(R.string.course_container_nav_downloads, Icons.Filled.CloudDownload), DISCUSSIONS(R.string.course_container_nav_discussions, Icons.AutoMirrored.Filled.Chat), - MORE(R.string.course_container_nav_more, Icons.AutoMirrored.Filled.TextSnippet) + MORE(R.string.course_container_nav_more, Icons.AutoMirrored.Filled.TextSnippet), } diff --git a/course/src/main/java/org/openedx/course/presentation/container/CourseContainerViewModel.kt b/course/src/main/java/org/openedx/course/presentation/container/CourseContainerViewModel.kt index f3d2bd2c7..18f5f9b3c 100644 --- a/course/src/main/java/org/openedx/course/presentation/container/CourseContainerViewModel.kt +++ b/course/src/main/java/org/openedx/course/presentation/container/CourseContainerViewModel.kt @@ -40,6 +40,7 @@ import org.openedx.core.system.notifier.CourseStructureGot import org.openedx.core.system.notifier.CourseStructureUpdated import org.openedx.core.system.notifier.RefreshDates import org.openedx.core.system.notifier.RefreshDiscussions +import org.openedx.core.system.notifier.RefreshProgress import org.openedx.core.worker.CalendarSyncScheduler import org.openedx.course.DatesShiftedSnackBar import org.openedx.course.domain.interactor.CourseInteractor @@ -303,6 +304,12 @@ class CourseContainerViewModel( } } + CourseContainerTab.PROGRESS -> { + viewModelScope.launch { + courseNotifier.send(RefreshProgress) + } + } + else -> { _refreshing.value = false } @@ -313,7 +320,7 @@ class CourseContainerViewModel( viewModelScope.launch { try { interactor.getCourseStructure(courseId, isNeedRefresh = true) - } catch (ignore: Exception) { + } catch (_: Exception) { _errorMessage.value = resourceManager.getString(CoreR.string.core_error_unknown_error) } @@ -328,6 +335,7 @@ class CourseContainerViewModel( CourseContainerTab.VIDEOS -> videoTabClickedEvent() CourseContainerTab.DISCUSSIONS -> discussionTabClickedEvent() CourseContainerTab.DATES -> datesTabClickedEvent() + CourseContainerTab.PROGRESS -> progressTabClickedEvent() CourseContainerTab.MORE -> moreTabClickedEvent() CourseContainerTab.OFFLINE -> {} } @@ -381,6 +389,10 @@ class CourseContainerViewModel( logCourseContainerEvent(CourseAnalyticsEvent.MORE_TAB) } + private fun progressTabClickedEvent() { + logCourseContainerEvent(CourseAnalyticsEvent.PROGRESS_TAB) + } + private fun logCourseContainerEvent(event: CourseAnalyticsEvent) { courseAnalytics.logScreenEvent( screenName = event.eventName, diff --git a/course/src/main/java/org/openedx/course/presentation/progress/CourseProgressScreen.kt b/course/src/main/java/org/openedx/course/presentation/progress/CourseProgressScreen.kt new file mode 100644 index 000000000..57b13d80b --- /dev/null +++ b/course/src/main/java/org/openedx/course/presentation/progress/CourseProgressScreen.kt @@ -0,0 +1,547 @@ +package org.openedx.course.presentation.progress + +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.IntrinsicSize +import androidx.compose.foundation.layout.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.offset +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material.CircularProgressIndicator +import androidx.compose.material.Divider +import androidx.compose.material.Icon +import androidx.compose.material.MaterialTheme +import androidx.compose.material.Scaffold +import androidx.compose.material.Surface +import androidx.compose.material.Text +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.outlined.InsertDriveFile +import androidx.compose.material.rememberScaffoldState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.StrokeCap +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.clearAndSetSemantics +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.withStyle +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import org.openedx.core.NoContentScreenType +import org.openedx.core.domain.model.CourseProgress +import org.openedx.core.ui.CircularProgress +import org.openedx.core.ui.HandleUIMessage +import org.openedx.core.ui.NoContentScreen +import org.openedx.core.ui.displayCutoutForLandscape +import org.openedx.core.ui.theme.appColors +import org.openedx.core.ui.theme.appShapes +import org.openedx.core.ui.theme.appTypography +import org.openedx.course.R +import org.openedx.foundation.presentation.UIMessage +import org.openedx.foundation.presentation.WindowSize +import org.openedx.foundation.presentation.windowSizeValue + +@Composable +fun CourseProgressScreen( + windowSize: WindowSize, + viewModel: CourseProgressViewModel, +) { + val uiState by viewModel.uiState.collectAsState() + val uiMessage by viewModel.uiMessage.collectAsState(null) + + when (val state = uiState) { + is CourseProgressUIState.Loading -> CircularProgress() + is CourseProgressUIState.Error -> NoContentScreen(NoContentScreenType.COURSE_PROGRESS) + is CourseProgressUIState.Data -> CourseProgressContent( + uiState = state, + uiMessage = uiMessage, + windowSize = windowSize, + ) + } +} + +@Composable +private fun CourseProgressContent( + uiState: CourseProgressUIState.Data, + uiMessage: UIMessage?, + windowSize: WindowSize +) { + val scaffoldState = rememberScaffoldState() + val gradingPolicy = uiState.progress.gradingPolicy + + Scaffold( + modifier = Modifier + .fillMaxSize(), + scaffoldState = scaffoldState, + backgroundColor = MaterialTheme.appColors.background + ) { + val screenWidth by remember(key1 = windowSize) { + mutableStateOf( + windowSize.windowSizeValue( + expanded = Modifier.widthIn(Dp.Unspecified, 560.dp), + compact = Modifier.fillMaxWidth() + ) + ) + } + + Box( + modifier = Modifier + .fillMaxSize() + .padding(it) + .displayCutoutForLandscape(), + contentAlignment = Alignment.TopCenter + ) { + Surface( + modifier = screenWidth, + color = MaterialTheme.appColors.background, + ) { + LazyColumn( + modifier = Modifier + .fillMaxSize() + .padding(horizontal = 16.dp), + verticalArrangement = Arrangement.spacedBy(16.dp), + contentPadding = PaddingValues(vertical = 16.dp) + ) { + item { + CourseCompletionView( + progress = uiState.progress + ) + } + if (gradingPolicy == null) return@LazyColumn + if (gradingPolicy.assignmentPolicies.isNotEmpty()) { + item { + OverallGradeView( + progress = uiState.progress, + ) + } + item { + GradeDetailsHeaderView() + } + itemsIndexed(gradingPolicy.assignmentPolicies) { index, policy -> + AssignmentTypeRow( + progress = uiState.progress, + policy = policy, + color = if (gradingPolicy.assignmentColors.isNotEmpty()) { + gradingPolicy.assignmentColors[index % gradingPolicy.assignmentColors.size] + } else { + MaterialTheme.appColors.primary + } + ) + Divider( + modifier = Modifier + .fillMaxWidth() + .padding(top = 8.dp) + ) + } + item { + GradeDetailsFooterView( + progress = uiState.progress + ) + } + } else { + item { + Box( + modifier = Modifier + .fillMaxSize() + .padding(top = 60.dp), + contentAlignment = Alignment.Center + ) { + NoGradesView() + } + } + } + } + } + + HandleUIMessage(uiMessage = uiMessage, scaffoldState = scaffoldState) + } + } +} + +@Composable +private fun NoGradesView() { + Column( + horizontalAlignment = Alignment.CenterHorizontally + ) { + Icon( + modifier = Modifier.size(60.dp), + imageVector = Icons.AutoMirrored.Outlined.InsertDriveFile, + contentDescription = null, + tint = MaterialTheme.appColors.divider + ) + Spacer(modifier = Modifier.height(12.dp)) + Text( + text = stringResource(R.string.course_progress_no_assignments), + style = MaterialTheme.appTypography.titleMedium, + color = MaterialTheme.appColors.textDark, + textAlign = TextAlign.Center + ) + } +} + +@Composable +private fun GradeDetailsHeaderView() { + Column( + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + Text( + text = stringResource(R.string.course_progress_grade_details), + style = MaterialTheme.appTypography.titleMedium, + color = MaterialTheme.appColors.textDark, + ) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween + ) { + Text( + text = stringResource(R.string.course_progress_assignment_type), + style = MaterialTheme.appTypography.bodySmall, + color = MaterialTheme.appColors.textPrimaryVariant, + ) + Text( + text = stringResource(R.string.course_progress_current_max), + style = MaterialTheme.appTypography.bodySmall, + color = MaterialTheme.appColors.textPrimaryVariant, + ) + } + } +} + +@Composable +private fun GradeDetailsFooterView( + progress: CourseProgress, +) { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween + ) { + Text( + text = stringResource(R.string.course_progress_current_overall), + style = MaterialTheme.appTypography.labelLarge, + color = MaterialTheme.appColors.textDark, + ) + Text( + text = "${progress.getTotalWeightPercent().toInt()}%", + style = MaterialTheme.appTypography.labelLarge, + color = MaterialTheme.appColors.primary, + fontWeight = FontWeight.SemiBold + ) + } +} + +@Composable +private fun OverallGradeView( + progress: CourseProgress, +) { + val gradingPolicy = progress.gradingPolicy + if (gradingPolicy == null) return + val notCompletedWeightedGradePercent = progress.getNotCompletedWeightedGradePercent() + Column( + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + Text( + text = stringResource(R.string.course_progress_overall_title), + style = MaterialTheme.appTypography.titleMedium, + color = MaterialTheme.appColors.textDark, + ) + Text( + text = stringResource(R.string.course_progress_overall_description), + style = MaterialTheme.appTypography.labelMedium, + color = MaterialTheme.appColors.textDark, + ) + Text( + text = buildAnnotatedString { + withStyle( + style = SpanStyle( + color = MaterialTheme.appColors.textDark, + fontSize = MaterialTheme.appTypography.labelMedium.fontSize, + fontFamily = MaterialTheme.appTypography.labelMedium.fontFamily, + fontWeight = MaterialTheme.appTypography.labelMedium.fontWeight + ) + ) { + append(stringResource(R.string.course_progress_current_overall) + " ") + } + withStyle( + style = SpanStyle( + color = MaterialTheme.appColors.primary, + fontSize = MaterialTheme.appTypography.labelMedium.fontSize, + fontFamily = MaterialTheme.appTypography.labelMedium.fontFamily, + fontWeight = FontWeight.SemiBold + ) + ) { + append("${progress.getTotalWeightPercent().toInt()}%") + } + }, + style = MaterialTheme.appTypography.labelMedium, + ) + + Column { + Row( + modifier = Modifier + .fillMaxWidth() + .height(8.dp) + .clip(CircleShape) + .border( + width = 1.dp, + color = MaterialTheme.appColors.gradeProgressBarBorder, + shape = CircleShape + ) + ) { + gradingPolicy.assignmentPolicies.forEachIndexed { index, assignmentPolicy -> + val assignmentColors = gradingPolicy.assignmentColors + val color = if (assignmentColors.isNotEmpty()) { + assignmentColors[ + gradingPolicy.assignmentPolicies.indexOf( + assignmentPolicy + ) % assignmentColors.size + ] + } else { + MaterialTheme.appColors.primary + } + val weightedPercent = + progress.getAssignmentWeightedGradedPercent(assignmentPolicy) + if (weightedPercent > 0f) { + Box( + modifier = Modifier + .weight(weightedPercent) + .background(color) + .fillMaxHeight() + ) + + // Add black separator between assignment policies (except after the last one) + if (index < gradingPolicy.assignmentPolicies.size - 1) { + Box( + modifier = Modifier + .width(1.dp) + .background(Color.Black) + .fillMaxHeight() + ) + } + } + } + if (notCompletedWeightedGradePercent > 0f) { + Box( + modifier = Modifier + .weight(notCompletedWeightedGradePercent) + .fillMaxHeight() + ) + } + } + Box( + modifier = Modifier + .fillMaxWidth(progress.requiredGrade), + contentAlignment = Alignment.CenterEnd + ) { + Box( + modifier = Modifier.offset(x = 20.dp), + contentAlignment = Alignment.Center + ) { + Icon( + painter = painterResource(id = R.drawable.ic_course_marker), + tint = MaterialTheme.appColors.warning, + contentDescription = null + ) + Text( + modifier = Modifier + .offset(y = 2.dp) + .clearAndSetSemantics { }, + text = "${progress.requiredGradePercent}%", + style = MaterialTheme.appTypography.labelMedium, + color = MaterialTheme.appColors.textDark, + ) + } + } + } + + Surface( + color = MaterialTheme.appColors.cardViewBackground, + shape = MaterialTheme.appShapes.cardShape, + border = BorderStroke( + width = 1.dp, + color = MaterialTheme.appColors.warning + ), + modifier = Modifier.fillMaxWidth() + ) { + Row( + modifier = Modifier.padding(vertical = 12.dp, horizontal = 16.dp), + ) { + Icon( + modifier = Modifier.size(16.dp), + painter = painterResource(id = android.R.drawable.ic_dialog_alert), + contentDescription = null, + tint = MaterialTheme.appColors.warning, + ) + Spacer(modifier = Modifier.width(8.dp)) + Text( + text = stringResource( + R.string.course_progress_required_grade_percent, + progress.requiredGradePercent.toString() + ), + style = MaterialTheme.appTypography.labelLarge, + color = MaterialTheme.appColors.textDark, + ) + } + } + } +} + +@Composable +private fun CourseCompletionView( + progress: CourseProgress +) { + Row( + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + Column( + modifier = Modifier.weight(1f), + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { + Text( + text = stringResource(R.string.course_progress_completion_title), + style = MaterialTheme.appTypography.titleMedium, + color = MaterialTheme.appColors.textDark, + ) + Text( + text = stringResource(R.string.course_progress_completion_description), + style = MaterialTheme.appTypography.labelMedium, + color = MaterialTheme.appColors.textDark, + ) + } + Box( + modifier = Modifier + .align(Alignment.CenterVertically) + .semantics(mergeDescendants = true) {} + ) { + CircularProgressIndicator( + modifier = Modifier + .size(100.dp) + .border( + width = 1.dp, + color = MaterialTheme.appColors.progressBarBackgroundColor, + shape = CircleShape + ) + .padding(3.dp), + progress = progress.completion, + color = MaterialTheme.appColors.progressBarColor, + backgroundColor = MaterialTheme.appColors.progressBarBackgroundColor, + strokeWidth = 10.dp, + strokeCap = StrokeCap.Round + ) + Column( + modifier = Modifier.align(Alignment.Center), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Text( + text = "${progress.completionPercent}%", + style = MaterialTheme.appTypography.headlineSmall, + color = MaterialTheme.appColors.primary, + ) + Text( + text = stringResource(R.string.course_completed), + style = MaterialTheme.appTypography.labelSmall, + color = MaterialTheme.appColors.textPrimaryVariant, + ) + } + } + } +} + +@Composable +private fun AssignmentTypeRow( + progress: CourseProgress, + policy: CourseProgress.GradingPolicy.AssignmentPolicy, + color: Color +) { + val earned = progress.getEarnedAssignmentProblems(policy) + val possible = progress.getPossibleAssignmentProblems(policy) + Column( + modifier = Modifier + .semantics(mergeDescendants = true) {} + ) { + Text( + text = policy.type, + style = MaterialTheme.appTypography.labelLarge, + color = MaterialTheme.appColors.textPrimary, + ) + Row( + Modifier + .fillMaxWidth() + .height(IntrinsicSize.Min) + .padding(vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Box( + modifier = Modifier + .fillMaxHeight() + .width(7.dp) + .background( + color = color, + shape = CircleShape + ) + ) + Spacer(modifier = Modifier.width(8.dp)) + Column( + modifier = Modifier.weight(1f), + verticalArrangement = Arrangement.spacedBy(4.dp) + ) { + Text( + text = stringResource( + R.string.progress_earned_possible_assignment_problems, + earned.toInt(), + possible.toInt() + ), + style = MaterialTheme.appTypography.bodySmall, + color = MaterialTheme.appColors.textDark, + ) + Text( + text = buildAnnotatedString { + withStyle(style = SpanStyle(fontWeight = FontWeight.Bold)) { + append("${(policy.weight * 100).toInt()}%") + } + append(" ") + append(stringResource(R.string.progress_of_grade)) + }, + style = MaterialTheme.appTypography.bodySmall, + color = MaterialTheme.appColors.textDark, + ) + } + Text( + stringResource( + R.string.progress_current_and_max_weighted_graded_percent, + progress.getAssignmentWeightedGradedPercent(policy).toInt(), + (policy.weight * 100).toInt() + ), + style = MaterialTheme.appTypography.bodyLarge, + fontWeight = FontWeight.W700, + color = MaterialTheme.appColors.textDark, + ) + } + } +} diff --git a/course/src/main/java/org/openedx/course/presentation/progress/CourseProgressUIState.kt b/course/src/main/java/org/openedx/course/presentation/progress/CourseProgressUIState.kt new file mode 100644 index 000000000..25771f631 --- /dev/null +++ b/course/src/main/java/org/openedx/course/presentation/progress/CourseProgressUIState.kt @@ -0,0 +1,9 @@ +package org.openedx.course.presentation.progress + +import org.openedx.core.domain.model.CourseProgress + +sealed class CourseProgressUIState { + data object Error : CourseProgressUIState() + data object Loading : CourseProgressUIState() + data class Data(val progress: CourseProgress) : CourseProgressUIState() +} diff --git a/course/src/main/java/org/openedx/course/presentation/progress/CourseProgressViewModel.kt b/course/src/main/java/org/openedx/course/presentation/progress/CourseProgressViewModel.kt new file mode 100644 index 000000000..c5c4b1f06 --- /dev/null +++ b/course/src/main/java/org/openedx/course/presentation/progress/CourseProgressViewModel.kt @@ -0,0 +1,72 @@ +package org.openedx.course.presentation.progress + +import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.catch +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.launch +import org.openedx.core.system.notifier.CourseLoading +import org.openedx.core.system.notifier.CourseNotifier +import org.openedx.core.system.notifier.CourseStructureUpdated +import org.openedx.core.system.notifier.RefreshProgress +import org.openedx.course.domain.interactor.CourseInteractor +import org.openedx.foundation.presentation.BaseViewModel +import org.openedx.foundation.presentation.UIMessage + +class CourseProgressViewModel( + val courseId: String, + private val interactor: CourseInteractor, + private val courseNotifier: CourseNotifier, +) : BaseViewModel() { + + private val _uiState = MutableStateFlow(CourseProgressUIState.Loading) + val uiState: StateFlow + get() = _uiState.asStateFlow() + + private val _uiMessage = MutableSharedFlow() + val uiMessage: SharedFlow + get() = _uiMessage.asSharedFlow() + + private var progressJob: Job? = null + + init { + loadCourseProgress(false) + collectCourseNotifier() + } + + fun loadCourseProgress(isRefresh: Boolean) { + progressJob?.cancel() + progressJob = viewModelScope.launch { + if (!isRefresh) { + _uiState.value = CourseProgressUIState.Loading + } + interactor.getCourseProgress(courseId, isRefresh) + .catch { e -> + if (_uiState.value !is CourseProgressUIState.Data) { + _uiState.value = CourseProgressUIState.Error + } + courseNotifier.send(CourseLoading(false)) + } + .collectLatest { progress -> + _uiState.value = CourseProgressUIState.Data(progress) + courseNotifier.send(CourseLoading(false)) + } + } + } + + private fun collectCourseNotifier() { + viewModelScope.launch { + courseNotifier.notifier.collect { event -> + when (event) { + is RefreshProgress, is CourseStructureUpdated -> loadCourseProgress(true) + } + } + } + } +} diff --git a/course/src/main/res/drawable/ic_course_marker.xml b/course/src/main/res/drawable/ic_course_marker.xml new file mode 100644 index 000000000..007f3425b --- /dev/null +++ b/course/src/main/res/drawable/ic_course_marker.xml @@ -0,0 +1,12 @@ + + + + diff --git a/course/src/main/res/values/strings.xml b/course/src/main/res/values/strings.xml index f8fa29850..47374e30f 100644 --- a/course/src/main/res/values/strings.xml +++ b/course/src/main/res/values/strings.xml @@ -25,6 +25,15 @@ Some content in this part of the course is locked for upgraded users only. You cannot change the download video quality when all videos are downloading Dates Shifted + Course Completion + This represents how much of the course content you have completed. Note that some content may not yet be released. + Overall Grade + This represents your weighted grade against the grade needed to pass this course. + Current Overall Weighted Grade: + A weighted grade of %1$s%% is required to pass this course + Grade Details + Assignment Type + Current / Max % @@ -34,6 +43,7 @@ More Dates Downloads + Progress Video player @@ -43,7 +53,6 @@ Section completed Section uncompleted - %1$s of %2$s assignment complete %1$s of %2$s assignments complete @@ -53,4 +62,9 @@ Your free audit access to this course expired on %s. This course will begin on %s. Come back then to start learning! An error occurred while loading your course + Completed + %1$s / %2$s Complete + of Grade + %1$s / %2$s%% + This course does not contain graded assignments. From 12f3d7575679a94fe17b93d71521ca66fe2e44cc Mon Sep 17 00:00:00 2001 From: PavloNetrebchuk <141041606+PavloNetrebchuk@users.noreply.github.com> Date: Tue, 19 Aug 2025 11:04:38 +0300 Subject: [PATCH 20/24] fix: handle NoCachedDataException (#460) --- .../java/org/openedx/app/di/ScreenModule.kt | 1 + .../offline/CourseOfflineViewModel.kt | 22 ++++++++++++++----- 2 files changed, 18 insertions(+), 5 deletions(-) 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 7f016ace9..25cf3fed4 100644 --- a/app/src/main/java/org/openedx/app/di/ScreenModule.kt +++ b/app/src/main/java/org/openedx/app/di/ScreenModule.kt @@ -494,6 +494,7 @@ val screenModule = module { get(), get(), get(), + get() ) } viewModel { (courseId: String) -> diff --git a/course/src/main/java/org/openedx/course/presentation/offline/CourseOfflineViewModel.kt b/course/src/main/java/org/openedx/course/presentation/offline/CourseOfflineViewModel.kt index 497ba799d..620b79012 100644 --- a/course/src/main/java/org/openedx/course/presentation/offline/CourseOfflineViewModel.kt +++ b/course/src/main/java/org/openedx/course/presentation/offline/CourseOfflineViewModel.kt @@ -25,6 +25,8 @@ import org.openedx.core.presentation.CoreAnalytics import org.openedx.core.presentation.dialog.downloaddialog.DownloadDialogItem import org.openedx.core.presentation.dialog.downloaddialog.DownloadDialogManager import org.openedx.core.system.connection.NetworkConnection +import org.openedx.core.system.notifier.CourseNotifier +import org.openedx.core.system.notifier.CourseStructureGot import org.openedx.course.domain.interactor.CourseInteractor import org.openedx.foundation.extension.toFileSize import org.openedx.foundation.utils.FileUtil @@ -37,6 +39,7 @@ class CourseOfflineViewModel( private val downloadDialogManager: DownloadDialogManager, private val fileUtil: FileUtil, private val networkConnection: NetworkConnection, + private val courseNotifier: CourseNotifier, coreAnalytics: CoreAnalytics, downloadDao: DownloadDao, workerController: DownloadWorkerController, @@ -71,11 +74,7 @@ class CourseOfflineViewModel( _uiState.update { it.copy(isDownloading = isDownloading) } } } - - viewModelScope.launch { - async { initDownloadFragment() }.await() - getOfflineData() - } + collectCourseNotifier() } fun downloadAllBlocks(fragmentManager: FragmentManager) { @@ -224,4 +223,17 @@ class CourseOfflineViewModel( } } } + + private fun collectCourseNotifier() { + viewModelScope.launch { + courseNotifier.notifier.collect { event -> + when (event) { + is CourseStructureGot -> { + async { initDownloadFragment() }.await() + getOfflineData() + } + } + } + } + } } From 8de8a4df09271a8aae2b106a97b9358a4fb44a44 Mon Sep 17 00:00:00 2001 From: PavloNetrebchuk <141041606+PavloNetrebchuk@users.noreply.github.com> Date: Mon, 1 Sep 2025 12:18:40 +0300 Subject: [PATCH 21/24] feat: [FC-0092] Course content tabs (#450) * feat: all content tab * feat: video content tab * feat: video tab progress * fix: video progress caching * fix: changes according demo feedback * fix: assignment tab UI * fix: connected data to assignment tab UI * feat: color coding, detekt fixes * feat: resume video * fix: changes according review * feat: changes according PR feedback * feat: empty state view * fix: changes according PR review feedback * fix: changes according QA review feedback * feat: analytics * fix: changes according QA review * feat: db automigration --- .../org.openedx.app.room.AppDatabase/4.json | 1236 +++++++++++++++++ .../java/org/openedx/app/di/ScreenModule.kt | 32 +- .../java/org/openedx/app/room/AppDatabase.kt | 8 +- .../org/openedx/app/room/DatabaseManager.kt | 2 +- .../org/openedx/core/NoContentScreenType.kt | 4 + .../core/data/model/AssignmentProgress.kt | 14 +- .../java/org/openedx/core/data/model/Block.kt | 6 +- .../core/data/model/CourseProgressResponse.kt | 2 + .../org/openedx/core/data/model/Progress.kt | 4 +- .../openedx/core/data/model/room/BlockDb.kt | 10 +- .../data/model/room/CourseProgressEntity.kt | 3 + .../data/model/room/VideoProgressEntity.kt | 18 + .../openedx/core/data/storage/CourseDao.kt | 23 +- .../core/domain/model/AssignmentProgress.kt | 22 +- .../org/openedx/core/domain/model/Block.kt | 46 +- .../org/openedx/core/domain/model/Progress.kt | 6 +- .../org/openedx/core/extension/ListExt.kt | 4 + .../module/download/BaseDownloadViewModel.kt | 22 +- .../notifier/CourseVideoPositionChanged.kt | 1 + .../java/org/openedx/core/ui/ComposeCommon.kt | 14 +- .../org/openedx/core/ui/theme/AppShapes.kt | 2 + .../org/openedx/core/utils/PreviewHelper.kt | 147 ++ .../java/org/openedx/core/utils/TimeUtils.kt | 19 + .../main/res/drawable/core_ic_mountains.xml | 34 + core/src/main/res/drawable/ic_core_check.xml | 12 + .../src/main/res/drawable/ic_core_pointer.xml | 9 + .../main/res/drawable/ic_core_watch_later.xml | 14 + core/src/main/res/values/strings.xml | 8 +- .../org/openedx/core/ui/theme/Colors.kt | 4 +- .../org/openedx/core/ui/theme/LocalShapes.kt | 4 +- .../data/repository/CourseRepository.kt | 16 + .../domain/interactor/CourseInteractor.kt | 2 + .../course/presentation/CourseAnalytics.kt | 29 + .../course/presentation/CourseRouter.kt | 3 - .../assignments/CourseAssignmentUIState.kt | 16 + .../assignments/CourseAssignmentViewModel.kt | 138 ++ .../CourseContentAssignmentScreen.kt | 706 ++++++++++ .../container/CourseContainerFragment.kt | 189 ++- .../container/CourseContainerTab.kt | 13 +- .../container/CourseContainerViewModel.kt | 17 +- .../contenttab/ContentTabEmptyState.kt | 122 ++ .../contenttab/ContentTabScreen.kt | 189 +++ .../contenttab/ContentTabViewModel.kt | 29 + ...ineScreen.kt => CourseContentAllScreen.kt} | 259 +--- ...eUIState.kt => CourseContentAllUIState.kt} | 8 +- ...wModel.kt => CourseContentAllViewModel.kt} | 39 +- .../progress/CourseProgressScreen.kt | 8 +- .../section/CourseSectionFragment.kt | 2 +- .../course/presentation/ui/CourseUI.kt | 525 +++++-- .../course/presentation/ui/CourseVideosUI.kt | 776 ----------- .../unit/video/EncodedVideoUnitViewModel.kt | 10 +- .../unit/video/VideoFullScreenFragment.kt | 1 + .../unit/video/VideoUnitFragment.kt | 2 +- .../unit/video/VideoUnitViewModel.kt | 37 +- .../presentation/unit/video/VideoViewModel.kt | 4 +- .../video/YoutubeVideoFullScreenFragment.kt | 5 + .../unit/video/YoutubeVideoUnitFragment.kt | 12 +- .../videos/CourseContentVideoScreen.kt | 390 ++++++ ...VideosUIState.kt => CourseVideoUIState.kt} | 16 +- .../videos/CourseVideoViewModel.kt | 170 +-- .../main/res/drawable/course_ic_warning.xml | 9 + .../res/drawable/course_video_play_button.xml | 12 + course/src/main/res/values/strings.xml | 36 +- .../outline/CourseOutlineViewModelTest.kt | 29 +- .../section/CourseSectionViewModelTest.kt | 3 +- .../CourseUnitContainerViewModelTest.kt | 3 +- .../unit/video/VideoUnitViewModelTest.kt | 10 +- .../unit/video/VideoViewModelTest.kt | 4 +- .../videos/CourseVideoViewModelTest.kt | 89 +- .../downloads/DownloadsViewModelTest.kt | 3 +- 70 files changed, 4258 insertions(+), 1403 deletions(-) create mode 100644 app/schemas/org.openedx.app.room.AppDatabase/4.json create mode 100644 core/src/main/java/org/openedx/core/data/model/room/VideoProgressEntity.kt create mode 100644 core/src/main/java/org/openedx/core/utils/PreviewHelper.kt create mode 100644 core/src/main/res/drawable/core_ic_mountains.xml create mode 100644 core/src/main/res/drawable/ic_core_check.xml create mode 100644 core/src/main/res/drawable/ic_core_pointer.xml create mode 100644 core/src/main/res/drawable/ic_core_watch_later.xml create mode 100644 course/src/main/java/org/openedx/course/presentation/assignments/CourseAssignmentUIState.kt create mode 100644 course/src/main/java/org/openedx/course/presentation/assignments/CourseAssignmentViewModel.kt create mode 100644 course/src/main/java/org/openedx/course/presentation/assignments/CourseContentAssignmentScreen.kt create mode 100644 course/src/main/java/org/openedx/course/presentation/contenttab/ContentTabEmptyState.kt create mode 100644 course/src/main/java/org/openedx/course/presentation/contenttab/ContentTabScreen.kt create mode 100644 course/src/main/java/org/openedx/course/presentation/contenttab/ContentTabViewModel.kt rename course/src/main/java/org/openedx/course/presentation/outline/{CourseOutlineScreen.kt => CourseContentAllScreen.kt} (70%) rename course/src/main/java/org/openedx/course/presentation/outline/{CourseOutlineUIState.kt => CourseContentAllUIState.kt} (80%) rename course/src/main/java/org/openedx/course/presentation/outline/{CourseOutlineViewModel.kt => CourseContentAllViewModel.kt} (93%) delete mode 100644 course/src/main/java/org/openedx/course/presentation/ui/CourseVideosUI.kt create mode 100644 course/src/main/java/org/openedx/course/presentation/videos/CourseContentVideoScreen.kt rename course/src/main/java/org/openedx/course/presentation/videos/{CourseVideosUIState.kt => CourseVideoUIState.kt} (55%) create mode 100644 course/src/main/res/drawable/course_ic_warning.xml create mode 100644 course/src/main/res/drawable/course_video_play_button.xml diff --git a/app/schemas/org.openedx.app.room.AppDatabase/4.json b/app/schemas/org.openedx.app.room.AppDatabase/4.json new file mode 100644 index 000000000..0f1e1c17b --- /dev/null +++ b/app/schemas/org.openedx.app.room.AppDatabase/4.json @@ -0,0 +1,1236 @@ +{ + "formatVersion": 1, + "database": { + "version": 4, + "identityHash": "488bd2b78e977fef626afb28014c80f2", + "entities": [ + { + "tableName": "course_discovery_table", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `blocksUrl` TEXT NOT NULL, `courseId` TEXT NOT NULL, `effort` TEXT NOT NULL, `enrollmentStart` TEXT NOT NULL, `enrollmentEnd` TEXT NOT NULL, `hidden` INTEGER NOT NULL, `invitationOnly` INTEGER NOT NULL, `mobileAvailable` INTEGER NOT NULL, `name` TEXT NOT NULL, `number` TEXT NOT NULL, `org` TEXT NOT NULL, `pacing` TEXT NOT NULL, `shortDescription` TEXT NOT NULL, `start` TEXT NOT NULL, `end` TEXT NOT NULL, `startDisplay` TEXT NOT NULL, `startType` TEXT NOT NULL, `overview` TEXT NOT NULL, `isEnrolled` INTEGER NOT NULL, `bannerImage` TEXT, `courseImage` TEXT, `courseVideo` TEXT, `image` TEXT, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "blocksUrl", + "columnName": "blocksUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "courseId", + "columnName": "courseId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "effort", + "columnName": "effort", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "enrollmentStart", + "columnName": "enrollmentStart", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "enrollmentEnd", + "columnName": "enrollmentEnd", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "hidden", + "columnName": "hidden", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "invitationOnly", + "columnName": "invitationOnly", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "mobileAvailable", + "columnName": "mobileAvailable", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "number", + "columnName": "number", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "org", + "columnName": "org", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "pacing", + "columnName": "pacing", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "shortDescription", + "columnName": "shortDescription", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "start", + "columnName": "start", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "end", + "columnName": "end", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "startDisplay", + "columnName": "startDisplay", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "startType", + "columnName": "startType", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "overview", + "columnName": "overview", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "isEnrolled", + "columnName": "isEnrolled", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "media.bannerImage", + "columnName": "bannerImage", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "media.courseImage", + "columnName": "courseImage", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "media.courseVideo", + "columnName": "courseVideo", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "media.image", + "columnName": "image", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "course_enrolled_table", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`courseId` TEXT NOT NULL, `auditAccessExpires` TEXT NOT NULL, `created` TEXT NOT NULL, `mode` TEXT NOT NULL, `isActive` INTEGER NOT NULL, `id` TEXT NOT NULL, `name` TEXT NOT NULL, `number` TEXT NOT NULL, `org` TEXT NOT NULL, `start` TEXT NOT NULL, `startDisplay` TEXT NOT NULL, `startType` TEXT NOT NULL, `end` TEXT NOT NULL, `dynamicUpgradeDeadline` TEXT NOT NULL, `subscriptionId` TEXT NOT NULL, `course_image_link` TEXT NOT NULL, `courseAbout` TEXT NOT NULL, `courseUpdates` TEXT NOT NULL, `courseHandouts` TEXT NOT NULL, `discussionUrl` TEXT NOT NULL, `videoOutline` TEXT NOT NULL, `isSelfPaced` INTEGER NOT NULL, `hasAccess` INTEGER, `errorCode` TEXT, `developerMessage` TEXT, `userMessage` TEXT, `additionalContextUserMessage` TEXT, `userFragment` TEXT, `bannerImage` TEXT, `courseImage` TEXT, `courseVideo` TEXT, `image` TEXT, `facebook` TEXT NOT NULL, `twitter` TEXT NOT NULL, `certificateURL` TEXT, `assignments_completed` INTEGER NOT NULL, `total_assignments_count` INTEGER NOT NULL, `lastVisitedModuleId` TEXT, `lastVisitedModulePath` TEXT, `lastVisitedBlockId` TEXT, `lastVisitedUnitDisplayName` TEXT, `futureAssignments` TEXT, `pastAssignments` TEXT, PRIMARY KEY(`courseId`))", + "fields": [ + { + "fieldPath": "courseId", + "columnName": "courseId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "auditAccessExpires", + "columnName": "auditAccessExpires", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "created", + "columnName": "created", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "mode", + "columnName": "mode", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "isActive", + "columnName": "isActive", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "course.id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "course.name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "course.number", + "columnName": "number", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "course.org", + "columnName": "org", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "course.start", + "columnName": "start", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "course.startDisplay", + "columnName": "startDisplay", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "course.startType", + "columnName": "startType", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "course.end", + "columnName": "end", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "course.dynamicUpgradeDeadline", + "columnName": "dynamicUpgradeDeadline", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "course.subscriptionId", + "columnName": "subscriptionId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "course.courseImage", + "columnName": "course_image_link", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "course.courseAbout", + "columnName": "courseAbout", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "course.courseUpdates", + "columnName": "courseUpdates", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "course.courseHandouts", + "columnName": "courseHandouts", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "course.discussionUrl", + "columnName": "discussionUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "course.videoOutline", + "columnName": "videoOutline", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "course.isSelfPaced", + "columnName": "isSelfPaced", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "course.coursewareAccess.hasAccess", + "columnName": "hasAccess", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "course.coursewareAccess.errorCode", + "columnName": "errorCode", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "course.coursewareAccess.developerMessage", + "columnName": "developerMessage", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "course.coursewareAccess.userMessage", + "columnName": "userMessage", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "course.coursewareAccess.additionalContextUserMessage", + "columnName": "additionalContextUserMessage", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "course.coursewareAccess.userFragment", + "columnName": "userFragment", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "course.media.bannerImage", + "columnName": "bannerImage", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "course.media.courseImage", + "columnName": "courseImage", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "course.media.courseVideo", + "columnName": "courseVideo", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "course.media.image", + "columnName": "image", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "course.courseSharingUtmParameters.facebook", + "columnName": "facebook", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "course.courseSharingUtmParameters.twitter", + "columnName": "twitter", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "certificate.certificateURL", + "columnName": "certificateURL", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "progress.assignmentsCompleted", + "columnName": "assignments_completed", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "progress.totalAssignmentsCount", + "columnName": "total_assignments_count", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "courseStatus.lastVisitedModuleId", + "columnName": "lastVisitedModuleId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "courseStatus.lastVisitedModulePath", + "columnName": "lastVisitedModulePath", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "courseStatus.lastVisitedBlockId", + "columnName": "lastVisitedBlockId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "courseStatus.lastVisitedUnitDisplayName", + "columnName": "lastVisitedUnitDisplayName", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "courseAssignments.futureAssignments", + "columnName": "futureAssignments", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "courseAssignments.pastAssignments", + "columnName": "pastAssignments", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "courseId" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "course_structure_table", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`root` TEXT NOT NULL, `id` TEXT NOT NULL, `blocks` TEXT NOT NULL, `name` TEXT NOT NULL, `number` TEXT NOT NULL, `org` TEXT NOT NULL, `start` TEXT, `startDisplay` TEXT NOT NULL, `startType` TEXT NOT NULL, `end` TEXT, `isSelfPaced` INTEGER NOT NULL, `hasAccess` INTEGER, `errorCode` TEXT, `developerMessage` TEXT, `userMessage` TEXT, `additionalContextUserMessage` TEXT, `userFragment` TEXT, `bannerImage` TEXT, `courseImage` TEXT, `courseVideo` TEXT, `image` TEXT, `certificateURL` TEXT, `assignments_completed` INTEGER NOT NULL, `total_assignments_count` INTEGER NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "root", + "columnName": "root", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "blocks", + "columnName": "blocks", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "number", + "columnName": "number", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "org", + "columnName": "org", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "start", + "columnName": "start", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "startDisplay", + "columnName": "startDisplay", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "startType", + "columnName": "startType", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "end", + "columnName": "end", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "isSelfPaced", + "columnName": "isSelfPaced", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "coursewareAccess.hasAccess", + "columnName": "hasAccess", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "coursewareAccess.errorCode", + "columnName": "errorCode", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "coursewareAccess.developerMessage", + "columnName": "developerMessage", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "coursewareAccess.userMessage", + "columnName": "userMessage", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "coursewareAccess.additionalContextUserMessage", + "columnName": "additionalContextUserMessage", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "coursewareAccess.userFragment", + "columnName": "userFragment", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "media.bannerImage", + "columnName": "bannerImage", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "media.courseImage", + "columnName": "courseImage", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "media.courseVideo", + "columnName": "courseVideo", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "media.image", + "columnName": "image", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "certificate.certificateURL", + "columnName": "certificateURL", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "progress.assignmentsCompleted", + "columnName": "assignments_completed", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "progress.totalAssignmentsCount", + "columnName": "total_assignments_count", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "download_model", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `title` TEXT NOT NULL, `courseId` TEXT NOT NULL, `size` INTEGER NOT NULL, `path` TEXT NOT NULL, `url` TEXT NOT NULL, `type` TEXT NOT NULL, `downloadedState` TEXT NOT NULL, `lastModified` TEXT, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "courseId", + "columnName": "courseId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "size", + "columnName": "size", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "path", + "columnName": "path", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "downloadedState", + "columnName": "downloadedState", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lastModified", + "columnName": "lastModified", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "offline_x_block_progress_table", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `courseId` TEXT NOT NULL, `url` TEXT NOT NULL, `type` TEXT NOT NULL, `data` TEXT NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "blockId", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "courseId", + "columnName": "courseId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "jsonProgress.url", + "columnName": "url", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "jsonProgress.type", + "columnName": "type", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "jsonProgress.data", + "columnName": "data", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "course_calendar_event_table", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`event_id` INTEGER NOT NULL, `course_id` TEXT NOT NULL, PRIMARY KEY(`event_id`))", + "fields": [ + { + "fieldPath": "eventId", + "columnName": "event_id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "courseId", + "columnName": "course_id", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "event_id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "course_calendar_state_table", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`course_id` TEXT NOT NULL, `checksum` INTEGER NOT NULL, `is_course_sync_enabled` INTEGER NOT NULL, PRIMARY KEY(`course_id`))", + "fields": [ + { + "fieldPath": "courseId", + "columnName": "course_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "checksum", + "columnName": "checksum", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isCourseSyncEnabled", + "columnName": "is_course_sync_enabled", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "course_id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "download_course_preview_table", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`course_id` TEXT NOT NULL, `course_name` TEXT, `course_image` TEXT, `total_size` INTEGER, PRIMARY KEY(`course_id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "course_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "course_name", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "image", + "columnName": "course_image", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "totalSize", + "columnName": "total_size", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "course_id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "course_enrollment_details_table", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `courseUpdates` TEXT NOT NULL, `courseHandouts` TEXT NOT NULL, `discussionUrl` TEXT NOT NULL, `hasUnmetPrerequisites` INTEGER NOT NULL, `isTooEarly` INTEGER NOT NULL, `isStaff` INTEGER NOT NULL, `auditAccessExpires` TEXT, `hasAccess` INTEGER, `errorCode` TEXT, `developerMessage` TEXT, `userMessage` TEXT, `additionalContextUserMessage` TEXT, `userFragment` TEXT, `certificateURL` TEXT, `created` TEXT, `mode` TEXT, `isActive` INTEGER NOT NULL, `upgradeDeadline` TEXT, `name` TEXT NOT NULL, `number` TEXT NOT NULL, `org` TEXT NOT NULL, `startDisplay` TEXT NOT NULL, `startType` TEXT NOT NULL, `isSelfPaced` INTEGER NOT NULL, `courseAbout` TEXT NOT NULL, `bannerImage` TEXT, `courseImage` TEXT, `courseVideo` TEXT, `image` TEXT, `facebook` TEXT NOT NULL, `twitter` TEXT NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "courseUpdates", + "columnName": "courseUpdates", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "courseHandouts", + "columnName": "courseHandouts", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "discussionUrl", + "columnName": "discussionUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "courseAccessDetails.hasUnmetPrerequisites", + "columnName": "hasUnmetPrerequisites", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "courseAccessDetails.isTooEarly", + "columnName": "isTooEarly", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "courseAccessDetails.isStaff", + "columnName": "isStaff", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "courseAccessDetails.auditAccessExpires", + "columnName": "auditAccessExpires", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "courseAccessDetails.coursewareAccess.hasAccess", + "columnName": "hasAccess", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "courseAccessDetails.coursewareAccess.errorCode", + "columnName": "errorCode", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "courseAccessDetails.coursewareAccess.developerMessage", + "columnName": "developerMessage", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "courseAccessDetails.coursewareAccess.userMessage", + "columnName": "userMessage", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "courseAccessDetails.coursewareAccess.additionalContextUserMessage", + "columnName": "additionalContextUserMessage", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "courseAccessDetails.coursewareAccess.userFragment", + "columnName": "userFragment", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "certificate.certificateURL", + "columnName": "certificateURL", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "enrollmentDetails.created", + "columnName": "created", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "enrollmentDetails.mode", + "columnName": "mode", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "enrollmentDetails.isActive", + "columnName": "isActive", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "enrollmentDetails.upgradeDeadline", + "columnName": "upgradeDeadline", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "courseInfoOverview.name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "courseInfoOverview.number", + "columnName": "number", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "courseInfoOverview.org", + "columnName": "org", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "courseInfoOverview.startDisplay", + "columnName": "startDisplay", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "courseInfoOverview.startType", + "columnName": "startType", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "courseInfoOverview.isSelfPaced", + "columnName": "isSelfPaced", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "courseInfoOverview.courseAbout", + "columnName": "courseAbout", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "courseInfoOverview.media.bannerImage", + "columnName": "bannerImage", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "courseInfoOverview.media.courseImage", + "columnName": "courseImage", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "courseInfoOverview.media.courseVideo", + "columnName": "courseVideo", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "courseInfoOverview.media.image", + "columnName": "image", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "courseInfoOverview.courseSharingUtmParameters.facebook", + "columnName": "facebook", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "courseInfoOverview.courseSharingUtmParameters.twitter", + "columnName": "twitter", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "video_progress_table", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`block_id` TEXT NOT NULL, `video_url` TEXT NOT NULL, `video_time` INTEGER NOT NULL, `duration` INTEGER NOT NULL, PRIMARY KEY(`block_id`))", + "fields": [ + { + "fieldPath": "blockId", + "columnName": "block_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "videoUrl", + "columnName": "video_url", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "videoTime", + "columnName": "video_time", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "duration", + "columnName": "duration", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "block_id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "course_progress_table", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`courseId` TEXT NOT NULL, `verifiedMode` TEXT NOT NULL, `accessExpiration` TEXT NOT NULL, `creditCourseRequirements` TEXT NOT NULL, `end` TEXT NOT NULL, `enrollmentMode` TEXT NOT NULL, `hasScheduledContent` INTEGER NOT NULL, `sectionScores` TEXT NOT NULL, `studioUrl` TEXT NOT NULL, `username` TEXT NOT NULL, `userHasPassingGrade` INTEGER NOT NULL, `disableProgressGraph` INTEGER NOT NULL, `certificate_certStatus` TEXT, `certificate_certWebViewUrl` TEXT, `certificate_downloadUrl` TEXT, `certificate_certificateAvailableDate` TEXT, `completion_completeCount` INTEGER, `completion_incompleteCount` INTEGER, `completion_lockedCount` INTEGER, `grade_letterGrade` TEXT, `grade_percent` REAL, `grade_isPassing` INTEGER, `grading_assignmentPolicies` TEXT, `grading_gradeRange` TEXT, `grading_assignmentColors` TEXT, `verification_link` TEXT, `verification_status` TEXT, `verification_statusDate` TEXT, PRIMARY KEY(`courseId`))", + "fields": [ + { + "fieldPath": "courseId", + "columnName": "courseId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "verifiedMode", + "columnName": "verifiedMode", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "accessExpiration", + "columnName": "accessExpiration", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "creditCourseRequirements", + "columnName": "creditCourseRequirements", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "end", + "columnName": "end", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "enrollmentMode", + "columnName": "enrollmentMode", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "hasScheduledContent", + "columnName": "hasScheduledContent", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "sectionScores", + "columnName": "sectionScores", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "studioUrl", + "columnName": "studioUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "username", + "columnName": "username", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "userHasPassingGrade", + "columnName": "userHasPassingGrade", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "disableProgressGraph", + "columnName": "disableProgressGraph", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "certificateData.certStatus", + "columnName": "certificate_certStatus", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "certificateData.certWebViewUrl", + "columnName": "certificate_certWebViewUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "certificateData.downloadUrl", + "columnName": "certificate_downloadUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "certificateData.certificateAvailableDate", + "columnName": "certificate_certificateAvailableDate", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "completionSummary.completeCount", + "columnName": "completion_completeCount", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "completionSummary.incompleteCount", + "columnName": "completion_incompleteCount", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "completionSummary.lockedCount", + "columnName": "completion_lockedCount", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "courseGrade.letterGrade", + "columnName": "grade_letterGrade", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "courseGrade.percent", + "columnName": "grade_percent", + "affinity": "REAL", + "notNull": false + }, + { + "fieldPath": "courseGrade.isPassing", + "columnName": "grade_isPassing", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "gradingPolicy.assignmentPolicies", + "columnName": "grading_assignmentPolicies", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "gradingPolicy.gradeRange", + "columnName": "grading_gradeRange", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "gradingPolicy.assignmentColors", + "columnName": "grading_assignmentColors", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "verificationData.link", + "columnName": "verification_link", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "verificationData.status", + "columnName": "verification_status", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "verificationData.statusDate", + "columnName": "verification_statusDate", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "courseId" + ] + }, + "indices": [], + "foreignKeys": [] + } + ], + "views": [], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '488bd2b78e977fef626afb28014c80f2')" + ] + } +} \ No newline at end of file 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 25cf3fed4..5d8f1eb5a 100644 --- a/app/src/main/java/org/openedx/app/di/ScreenModule.kt +++ b/app/src/main/java/org/openedx/app/di/ScreenModule.kt @@ -18,11 +18,13 @@ import org.openedx.core.presentation.settings.video.VideoQualityViewModel import org.openedx.core.repository.CalendarRepository import org.openedx.course.data.repository.CourseRepository import org.openedx.course.domain.interactor.CourseInteractor +import org.openedx.course.presentation.assignments.CourseAssignmentViewModel import org.openedx.course.presentation.container.CourseContainerViewModel +import org.openedx.course.presentation.contenttab.ContentTabViewModel import org.openedx.course.presentation.dates.CourseDatesViewModel import org.openedx.course.presentation.handouts.HandoutsViewModel import org.openedx.course.presentation.offline.CourseOfflineViewModel -import org.openedx.course.presentation.outline.CourseOutlineViewModel +import org.openedx.course.presentation.outline.CourseContentAllViewModel import org.openedx.course.presentation.progress.CourseProgressViewModel import org.openedx.course.presentation.section.CourseSectionViewModel import org.openedx.course.presentation.unit.container.CourseUnitContainerViewModel @@ -281,7 +283,7 @@ val screenModule = module { ) } viewModel { (courseId: String, courseTitle: String) -> - CourseOutlineViewModel( + CourseContentAllViewModel( courseId, courseTitle, get(), @@ -300,6 +302,13 @@ val screenModule = module { get(), ) } + viewModel { (courseId: String, courseTitle: String) -> + ContentTabViewModel( + courseId, + courseTitle, + get(), + ) + } viewModel { (courseId: String) -> CourseSectionViewModel( courseId, @@ -320,10 +329,9 @@ val screenModule = module { get(), ) } - viewModel { (courseId: String, courseTitle: String) -> + viewModel { (courseId: String) -> CourseVideoViewModel( courseId, - courseTitle, get(), get(), get(), @@ -343,9 +351,11 @@ val screenModule = module { } viewModel { (courseId: String) -> BaseVideoViewModel(courseId, get()) } viewModel { (courseId: String) -> VideoViewModel(courseId, get(), get(), get(), get()) } - viewModel { (courseId: String) -> + viewModel { (courseId: String, videoUrl: String, blockId: String) -> VideoUnitViewModel( courseId, + videoUrl, + blockId, get(), get(), get(), @@ -353,9 +363,10 @@ val screenModule = module { get() ) } - viewModel { (courseId: String, blockId: String) -> + viewModel { (courseId: String, videoUrl: String, blockId: String) -> EncodedVideoUnitViewModel( courseId, + videoUrl, blockId, get(), get(), @@ -538,4 +549,13 @@ val screenModule = module { router = get() ) } + viewModel { (courseId: String) -> + CourseAssignmentViewModel( + courseId = courseId, + interactor = get(), + courseRouter = get(), + courseNotifier = get(), + analytics = get() + ) + } } diff --git a/app/src/main/java/org/openedx/app/room/AppDatabase.kt b/app/src/main/java/org/openedx/app/room/AppDatabase.kt index b5dfde4da..fd0b0069f 100644 --- a/app/src/main/java/org/openedx/app/room/AppDatabase.kt +++ b/app/src/main/java/org/openedx/app/room/AppDatabase.kt @@ -11,6 +11,7 @@ import org.openedx.core.data.model.room.CourseProgressEntity import org.openedx.core.data.model.room.CourseStructureEntity import org.openedx.core.data.model.room.DownloadCoursePreview import org.openedx.core.data.model.room.OfflineXBlockProgress +import org.openedx.core.data.model.room.VideoProgressEntity import org.openedx.core.data.model.room.discovery.EnrolledCourseEntity import org.openedx.core.data.storage.CourseDao import org.openedx.core.module.db.CalendarDao @@ -22,9 +23,10 @@ import org.openedx.discovery.data.converter.DiscoveryConverter import org.openedx.discovery.data.model.room.CourseEntity import org.openedx.discovery.data.storage.DiscoveryDao -const val DATABASE_VERSION = 3 +const val DATABASE_VERSION = 4 const val DATABASE_NAME = "OpenEdX_db" +@Suppress("MagicNumber") @Database( entities = [ CourseEntity::class, @@ -36,11 +38,13 @@ const val DATABASE_NAME = "OpenEdX_db" CourseCalendarStateEntity::class, DownloadCoursePreview::class, CourseEnrollmentDetailsEntity::class, + VideoProgressEntity::class, CourseProgressEntity::class, ], autoMigrations = [ AutoMigration(1, 2), - AutoMigration(2, DATABASE_VERSION), + AutoMigration(2, 3), + AutoMigration(3, DATABASE_VERSION), ], version = DATABASE_VERSION ) diff --git a/app/src/main/java/org/openedx/app/room/DatabaseManager.kt b/app/src/main/java/org/openedx/app/room/DatabaseManager.kt index 0dd6ce937..0c3087abf 100644 --- a/app/src/main/java/org/openedx/app/room/DatabaseManager.kt +++ b/app/src/main/java/org/openedx/app/room/DatabaseManager.kt @@ -17,7 +17,7 @@ class DatabaseManager( ) : DatabaseManager { override fun clearTables() { CoroutineScope(Dispatchers.IO).launch { - courseDao.clearCourseData() + courseDao.clearCachedData() dashboardDao.clearCachedData() downloadDao.clearOfflineProgress() discoveryDao.clearCachedData() diff --git a/core/src/main/java/org/openedx/core/NoContentScreenType.kt b/core/src/main/java/org/openedx/core/NoContentScreenType.kt index 1b9dcafab..559cf05d1 100644 --- a/core/src/main/java/org/openedx/core/NoContentScreenType.kt +++ b/core/src/main/java/org/openedx/core/NoContentScreenType.kt @@ -16,6 +16,10 @@ enum class NoContentScreenType( iconResId = R.drawable.core_ic_no_content, messageResId = R.string.core_no_dates ), + COURSE_ASSIGNMENT( + iconResId = R.drawable.core_ic_no_content, + messageResId = R.string.core_no_assignments + ), COURSE_DISCUSSIONS( iconResId = R.drawable.core_ic_no_content, messageResId = R.string.core_no_discussion 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 2ac10cb18..8c4d20e35 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 @@ -4,6 +4,8 @@ import com.google.gson.annotations.SerializedName import org.openedx.core.data.model.room.AssignmentProgressDb import org.openedx.core.domain.model.AssignmentProgress +private const val DEFAULT_LABEL_LENGTH = 5 + data class AssignmentProgress( @SerializedName("assignment_type") val assignmentType: String?, @@ -11,16 +13,20 @@ data class AssignmentProgress( val numPointsEarned: Float?, @SerializedName("num_points_possible") val numPointsPossible: Float?, + @SerializedName("short_label") + val shortLabel: String? ) { - fun mapToDomain() = AssignmentProgress( - assignmentType = assignmentType ?: "", + fun mapToDomain(displayName: String) = AssignmentProgress( + assignmentType = assignmentType, numPointsEarned = numPointsEarned ?: 0f, - numPointsPossible = numPointsPossible ?: 0f + numPointsPossible = numPointsPossible ?: 0f, + shortLabel = shortLabel ?: displayName.take(DEFAULT_LABEL_LENGTH) ) fun mapToRoomEntity() = AssignmentProgressDb( assignmentType = assignmentType, numPointsEarned = numPointsEarned, - numPointsPossible = numPointsPossible + numPointsPossible = numPointsPossible, + shortLabel = shortLabel ) } 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 8ac8a8378..c85a4c1b5 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 @@ -65,7 +65,7 @@ data class Block( blockCounts = blockCounts?.mapToDomain()!!, completion = completion ?: 0.0, containsGatedContent = containsGatedContent ?: false, - assignmentProgress = assignmentProgress?.mapToDomain(), + assignmentProgress = assignmentProgress?.mapToDomain(displayName.orEmpty()), due = TimeUtils.iso8601ToDate(due.orEmpty()), offlineDownload = offlineDownload?.mapToDomain() ) @@ -136,7 +136,9 @@ data class VideoInfo( var fileSize: Long? ) { fun mapToDomain() = DomainVideoInfo( - url = url.orEmpty(), + url = url + .orEmpty() + .trim(), fileSize = fileSize ?: 0 ) } diff --git a/core/src/main/java/org/openedx/core/data/model/CourseProgressResponse.kt b/core/src/main/java/org/openedx/core/data/model/CourseProgressResponse.kt index bf31419e6..00d55a9b5 100644 --- a/core/src/main/java/org/openedx/core/data/model/CourseProgressResponse.kt +++ b/core/src/main/java/org/openedx/core/data/model/CourseProgressResponse.kt @@ -157,6 +157,7 @@ data class CourseProgressResponse( displayName = displayName ?: "", subsections = subsections?.map { it.mapToDomain() } ?: emptyList() ) + data class Subsection( @SerializedName("assignment_type") val assignmentType: String?, @SerializedName("block_key") val blockKey: String?, @@ -203,6 +204,7 @@ data class CourseProgressResponse( showGrades = showGrades ?: false, url = url ?: "" ) + data class ProblemScore( @SerializedName("earned") val earned: Double?, @SerializedName("possible") val possible: Double? 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 d4813c14c..469be14b9 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 @@ -11,8 +11,8 @@ data class Progress( val totalAssignmentsCount: Int?, ) { fun mapToDomain() = Progress( - assignmentsCompleted = assignmentsCompleted ?: 0, - totalAssignmentsCount = totalAssignmentsCount ?: 0 + completed = assignmentsCompleted ?: 0, + total = totalAssignmentsCount ?: 0 ) fun mapToRoomEntity() = ProgressDb( 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 a60d9e68c..4ec631f30 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 @@ -203,7 +203,9 @@ data class VideoInfoDb( fun createFrom(videoInfo: VideoInfo?): VideoInfoDb? { if (videoInfo == null) return null return VideoInfoDb( - videoInfo.url ?: "", + videoInfo.url + .orEmpty() + .trim(), videoInfo.fileSize ?: 0, ) } @@ -230,11 +232,13 @@ data class AssignmentProgressDb( val numPointsEarned: Float?, @ColumnInfo("num_points_possible") val numPointsPossible: Float?, + val shortLabel: String? ) { fun mapToDomain() = DomainAssignmentProgress( - assignmentType = assignmentType ?: "", + assignmentType = assignmentType, numPointsEarned = numPointsEarned ?: 0f, - numPointsPossible = numPointsPossible ?: 0f + numPointsPossible = numPointsPossible ?: 0f, + shortLabel = shortLabel ?: "" ) } diff --git a/core/src/main/java/org/openedx/core/data/model/room/CourseProgressEntity.kt b/core/src/main/java/org/openedx/core/data/model/room/CourseProgressEntity.kt index 6c98cbed2..19ad78590 100644 --- a/core/src/main/java/org/openedx/core/data/model/room/CourseProgressEntity.kt +++ b/core/src/main/java/org/openedx/core/data/model/room/CourseProgressEntity.kt @@ -131,6 +131,7 @@ data class GradingPolicyDb( Color(colorString.toColorInt()) } ) + data class AssignmentPolicyDb( @ColumnInfo("numDroppable") val numDroppable: Int, @@ -163,6 +164,7 @@ data class SectionScoreDb( displayName = displayName, subsections = subsections.map { it.mapToDomain() } ) + data class SubsectionDb( @ColumnInfo("assignmentType") val assignmentType: String, @@ -206,6 +208,7 @@ data class SectionScoreDb( showGrades = showGrades, url = url ) + data class ProblemScoreDb( @ColumnInfo("earned") val earned: Double, diff --git a/core/src/main/java/org/openedx/core/data/model/room/VideoProgressEntity.kt b/core/src/main/java/org/openedx/core/data/model/room/VideoProgressEntity.kt new file mode 100644 index 000000000..fbe2866e7 --- /dev/null +++ b/core/src/main/java/org/openedx/core/data/model/room/VideoProgressEntity.kt @@ -0,0 +1,18 @@ +package org.openedx.core.data.model.room + +import androidx.room.ColumnInfo +import androidx.room.Entity +import androidx.room.PrimaryKey + +@Entity(tableName = "video_progress_table") +data class VideoProgressEntity( + @PrimaryKey + @ColumnInfo("block_id") + val blockId: String, + @ColumnInfo("video_url") + val videoUrl: String, + @ColumnInfo("video_time") + val videoTime: Long, + @ColumnInfo("duration") + val duration: Long, +) diff --git a/core/src/main/java/org/openedx/core/data/storage/CourseDao.kt b/core/src/main/java/org/openedx/core/data/storage/CourseDao.kt index 14ac6713a..4ca7db3a6 100644 --- a/core/src/main/java/org/openedx/core/data/storage/CourseDao.kt +++ b/core/src/main/java/org/openedx/core/data/storage/CourseDao.kt @@ -8,6 +8,7 @@ import androidx.room.Transaction import org.openedx.core.data.model.room.CourseEnrollmentDetailsEntity import org.openedx.core.data.model.room.CourseProgressEntity import org.openedx.core.data.model.room.CourseStructureEntity +import org.openedx.core.data.model.room.VideoProgressEntity @Dao interface CourseDao { @@ -19,27 +20,37 @@ interface CourseDao { suspend fun insertCourseStructureEntity(vararg courseStructureEntity: CourseStructureEntity) @Transaction - suspend fun clearCourseData() { - clearCourseStructureData() - clearCourseProgressData() + suspend fun clearCachedData() { + clearCourseStructure() + clearVideoProgress() clearEnrollmentCachedData() + clearCourseProgressData() } @Query("DELETE FROM course_structure_table") - suspend fun clearCourseStructureData() + suspend fun clearCourseStructure() - @Query("DELETE FROM course_progress_table") - suspend fun clearCourseProgressData() + @Query("DELETE FROM video_progress_table") + suspend fun clearVideoProgress() @Query("DELETE FROM course_enrollment_details_table") suspend fun clearEnrollmentCachedData() + @Query("DELETE FROM course_progress_table") + suspend fun clearCourseProgressData() + @Insert(onConflict = OnConflictStrategy.REPLACE) suspend fun insertCourseEnrollmentDetailsEntity(vararg courseEnrollmentDetailsEntity: CourseEnrollmentDetailsEntity) @Query("SELECT * FROM course_enrollment_details_table WHERE id=:id") suspend fun getCourseEnrollmentDetailsById(id: String): CourseEnrollmentDetailsEntity? + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun insertVideoProgressEntity(vararg videoProgressEntity: VideoProgressEntity) + + @Query("SELECT * FROM video_progress_table WHERE block_id=:blockId") + suspend fun getVideoProgressByBlockId(blockId: String): VideoProgressEntity? + @Insert(onConflict = OnConflictStrategy.REPLACE) suspend fun insertCourseProgressEntity(vararg courseProgressEntity: CourseProgressEntity) 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 index 730bfbfba..6c51810fb 100644 --- a/core/src/main/java/org/openedx/core/domain/model/AssignmentProgress.kt +++ b/core/src/main/java/org/openedx/core/domain/model/AssignmentProgress.kt @@ -1,11 +1,27 @@ package org.openedx.core.domain.model import android.os.Parcelable +import kotlinx.parcelize.IgnoredOnParcel import kotlinx.parcelize.Parcelize +import org.openedx.core.extension.safeDivBy @Parcelize data class AssignmentProgress( - val assignmentType: String, + val assignmentType: String?, val numPointsEarned: Float, - val numPointsPossible: Float -) : Parcelable + val numPointsPossible: Float, + val shortLabel: String +) : Parcelable { + + @IgnoredOnParcel + val value: Float = numPointsEarned.safeDivBy(numPointsPossible) + + fun toPointString(separator: String = ""): String { + return "${numPointsEarned.toInt()}$separator/$separator${numPointsPossible.toInt()}" + } + + @IgnoredOnParcel + val label = shortLabel + .replace(" ", "") + .replaceFirst(Regex("^(\\D+)(0*)(\\d+)$"), "$1$3") +} 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 d2c36a0f3..4b27c87fd 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 @@ -1,5 +1,6 @@ package org.openedx.core.domain.model +import android.content.Context import android.os.Parcelable import android.webkit.URLUtil import kotlinx.parcelize.Parcelize @@ -7,8 +8,9 @@ import kotlinx.parcelize.RawValue import org.openedx.core.AppDataConstants import org.openedx.core.BlockType 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.PreviewHelper +import org.openedx.core.utils.VideoPreview import org.openedx.core.utils.VideoUtil import java.util.Date @@ -51,13 +53,6 @@ data class Block( null } - fun isDownloading(): Boolean { - return downloadModel?.downloadedState == DownloadedState.DOWNLOADING || - downloadModel?.downloadedState == DownloadedState.WAITING - } - - fun isDownloaded() = downloadModel?.downloadedState == DownloadedState.DOWNLOADED - fun isGated() = containsGatedContent fun isCompleted() = completion == 1.0 @@ -89,6 +84,36 @@ data class Block( } } + fun getVideoPreview(context: Context, isOnline: Boolean, offlineUrl: String?): VideoPreview? { + return if (studentViewData?.encodedVideos?.hasYoutubeUrl == true) { + val youtubeUrl = studentViewData.encodedVideos.youtube?.url ?: "" + VideoPreview.createYoutubePreview( + PreviewHelper.getYouTubeThumbnailUrl(youtubeUrl) + ) + } else if (studentViewData?.encodedVideos?.hasVideoUrl == true) { + val videoUrl = if (studentViewData.encodedVideos.videoUrl.isNotEmpty() && isOnline) { + studentViewData.encodedVideos.videoUrl + } else { + offlineUrl ?: "" + } + val bitmap = PreviewHelper.getVideoFrameBitmap( + context = context, + isOnline = isOnline, + videoUrl = videoUrl + ) + bitmap?.let { VideoPreview.createEncodedVideoPreview(it) } + } else { + null + } + } + + val videoUrl: String? + get() = if (studentViewData?.encodedVideos?.hasVideoUrl == true) { + studentViewData.encodedVideos.videoUrl + } else { + studentViewData?.encodedVideos?.youtube?.url + } + val isVideoBlock get() = type == BlockType.VIDEO val isDiscussionBlock get() = type == BlockType.DISCUSSION val isHTMLBlock get() = type == BlockType.HTML @@ -169,7 +194,10 @@ data class EncodedVideos( isPreferredVideoInfo(mobileHigh) -> mobileHigh isPreferredVideoInfo(desktopMp4) -> desktopMp4 fallback != null && isPreferredVideoInfo(fallback) && - !VideoUtil.videoHasFormat(fallback!!.url, AppDataConstants.VIDEO_FORMAT_M3U8) -> fallback + !VideoUtil.videoHasFormat( + fallback!!.url, + AppDataConstants.VIDEO_FORMAT_M3U8 + ) -> fallback hls != null && isPreferredVideoInfo(hls) -> hls else -> null 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 edbcf0f90..fbe82d5cc 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 @@ -7,12 +7,12 @@ import org.openedx.core.extension.safeDivBy @Parcelize data class Progress( - val assignmentsCompleted: Int, - val totalAssignmentsCount: Int, + val completed: Int, + val total: Int, ) : Parcelable { @IgnoredOnParcel - val value: Float = assignmentsCompleted.toFloat().safeDivBy(totalAssignmentsCount.toFloat()) + val value: Float = completed.toFloat().safeDivBy(total.toFloat()) companion object { val DEFAULT_PROGRESS = Progress(0, 0) diff --git a/core/src/main/java/org/openedx/core/extension/ListExt.kt b/core/src/main/java/org/openedx/core/extension/ListExt.kt index 6d97816ae..6a802755f 100644 --- a/core/src/main/java/org/openedx/core/extension/ListExt.kt +++ b/core/src/main/java/org/openedx/core/extension/ListExt.kt @@ -10,3 +10,7 @@ fun List.getVerticalBlocks(): List { fun List.getSequentialBlocks(): List { return this.filter { it.type == BlockType.SEQUENTIAL } } + +fun List.getChapterBlocks(): List { + return this.filter { it.type == BlockType.CHAPTER } +} diff --git a/core/src/main/java/org/openedx/core/module/download/BaseDownloadViewModel.kt b/core/src/main/java/org/openedx/core/module/download/BaseDownloadViewModel.kt index 1f4de150a..ba87e6ab0 100644 --- a/core/src/main/java/org/openedx/core/module/download/BaseDownloadViewModel.kt +++ b/core/src/main/java/org/openedx/core/module/download/BaseDownloadViewModel.kt @@ -34,7 +34,6 @@ abstract class BaseDownloadViewModel( private val _downloadModelsStatusFlow = MutableSharedFlow>() protected val downloadModelsStatusFlow = _downloadModelsStatusFlow.asSharedFlow() - private var downloadingModelsList = listOf() private val _downloadingModelsFlow = MutableSharedFlow>() protected val downloadingModelsFlow = _downloadingModelsFlow.asSharedFlow() @@ -53,7 +52,7 @@ abstract class BaseDownloadViewModel( _downloadModelsStatusFlow.emit(downloadModelsStatus) } - private suspend fun getDownloadModelList(): List { + suspend fun getDownloadModelList(): List { return downloadDao.getAllDataFlow().first().map { it.mapToDomain() } } @@ -198,8 +197,6 @@ abstract class BaseDownloadViewModel( ) } - fun hasDownloadModelsInQueue() = downloadingModelsList.isNotEmpty() - fun getDownloadableChildren(id: String) = downloadableChildrenMap[id] open fun removeDownloadModels(blockId: String, courseId: String) { @@ -210,13 +207,6 @@ abstract class BaseDownloadViewModel( } } - fun removeAllDownloadModels() { - viewModelScope.launch { - val downloadableChildren = downloadableChildrenMap.values.flatten() - workerController.removeModels(downloadableChildren) - } - } - fun removeBlockDownloadModel(blockId: String) { viewModelScope.launch { workerController.removeModel(blockId) @@ -244,16 +234,6 @@ abstract class BaseDownloadViewModel( downloadableChildrenMap[parentId] = children + childId } - fun logBulkDownloadToggleEvent(toggle: Boolean, courseId: String) { - logEvent( - CoreAnalyticsEvent.VIDEO_BULK_DOWNLOAD_TOGGLE, - buildMap { - put(CoreAnalyticsKey.ACTION.key, toggle) - }, - courseId - ) - } - private fun logSubsectionDownloadEvent( subsectionId: String, numberOfVideos: Int, diff --git a/core/src/main/java/org/openedx/core/system/notifier/CourseVideoPositionChanged.kt b/core/src/main/java/org/openedx/core/system/notifier/CourseVideoPositionChanged.kt index bdeba1114..a289abe91 100644 --- a/core/src/main/java/org/openedx/core/system/notifier/CourseVideoPositionChanged.kt +++ b/core/src/main/java/org/openedx/core/system/notifier/CourseVideoPositionChanged.kt @@ -3,5 +3,6 @@ package org.openedx.core.system.notifier data class CourseVideoPositionChanged( val videoUrl: String, val videoTime: Long, + val duration: Long, val isPlaying: Boolean ) : 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 3cf6eb1fc..eed214567 100644 --- a/core/src/main/java/org/openedx/core/ui/ComposeCommon.kt +++ b/core/src/main/java/org/openedx/core/ui/ComposeCommon.kt @@ -19,6 +19,7 @@ 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.sizeIn import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.lazy.LazyColumn @@ -983,7 +984,9 @@ fun OfflineModeDialog( @Composable fun OpenEdXButton( - modifier: Modifier = Modifier.fillMaxWidth(), + modifier: Modifier = Modifier + .fillMaxWidth() + .height(42.dp), text: String = "", onClick: () -> Unit, enabled: Boolean = true, @@ -994,8 +997,7 @@ fun OpenEdXButton( Button( modifier = Modifier .testTag("btn_${text.tagId()}") - .then(modifier) - .height(42.dp), + .then(modifier), shape = MaterialTheme.appShapes.buttonShape, colors = ButtonDefaults.buttonColors( backgroundColor = backgroundColor @@ -1141,7 +1143,11 @@ fun NoContentScreen(message: String, icon: Painter) { horizontalAlignment = Alignment.CenterHorizontally ) { Icon( - modifier = Modifier.size(80.dp), + modifier = Modifier + .sizeIn( + maxWidth = 80.dp, + maxHeight = 80.dp + ), painter = icon, contentDescription = null, tint = MaterialTheme.appColors.progressBarBackgroundColor, diff --git a/core/src/main/java/org/openedx/core/ui/theme/AppShapes.kt b/core/src/main/java/org/openedx/core/ui/theme/AppShapes.kt index eed4d481d..1a45681f9 100644 --- a/core/src/main/java/org/openedx/core/ui/theme/AppShapes.kt +++ b/core/src/main/java/org/openedx/core/ui/theme/AppShapes.kt @@ -13,9 +13,11 @@ data class AppShapes( val textFieldShape: CornerBasedShape, val screenBackgroundShape: CornerBasedShape, val cardShape: CornerBasedShape, + val sectionCardShape: CornerBasedShape, val screenBackgroundShapeFull: CornerBasedShape, val courseImageShape: CornerBasedShape, val dialogShape: CornerBasedShape, + val videoPreviewShape: CornerBasedShape, ) val MaterialTheme.appShapes: AppShapes diff --git a/core/src/main/java/org/openedx/core/utils/PreviewHelper.kt b/core/src/main/java/org/openedx/core/utils/PreviewHelper.kt new file mode 100644 index 000000000..03227050b --- /dev/null +++ b/core/src/main/java/org/openedx/core/utils/PreviewHelper.kt @@ -0,0 +1,147 @@ +package org.openedx.core.utils + +import android.content.Context +import android.graphics.Bitmap +import android.graphics.BitmapFactory +import android.media.MediaMetadataRetriever +import java.io.File +import java.io.FileOutputStream +import java.security.MessageDigest + +data class VideoPreview( + val link: String? = null, + val bitmap: Bitmap? = null +) { + companion object { + fun createYoutubePreview(link: String): VideoPreview { + return VideoPreview(link = link) + } + + fun createEncodedVideoPreview(bitmap: Bitmap): VideoPreview { + return VideoPreview(bitmap = bitmap) + } + } +} + +object PreviewHelper { + + fun getYouTubeThumbnailUrl(url: String): String { + val videoId = extractYouTubeVideoId(url) + return "https://img.youtube.com/vi/$videoId/0.jpg" + } + + private fun extractYouTubeVideoId(url: String): String { + val regex = Regex( + "^(?:https?://)?(?:www\\.)?(?:youtube\\.com/(?:[^/]+/.+/|(?:v|e(?:mbed)?)|.*[?&]v=)|youtu\\.be/)" + + "([^\"&?/\\s]{11})", + RegexOption.IGNORE_CASE + ) + val matchResult = regex.find(url) + return matchResult?.groups?.get(1)?.value ?: "" + } + + fun getVideoFrameBitmap(context: Context, isOnline: Boolean, videoUrl: String): Bitmap? { + var result: Bitmap? = null + if (isOnline || isLocalFile(videoUrl)) { + // Check cache first + val cacheFile = getCacheFile(context, videoUrl) + result = if (cacheFile.exists()) { + try { + BitmapFactory.decodeFile(cacheFile.absolutePath) + } catch (_: Exception) { + extractBitmapFromVideo(videoUrl, context) + } + } else { + extractBitmapFromVideo(videoUrl, context) + } + } + return result + } + + private fun extractBitmapFromVideo(videoUrl: String, context: Context): Bitmap? { + val retriever = MediaMetadataRetriever() + try { + if (isLocalFile(videoUrl)) { + retriever.setDataSource(videoUrl) + } else { + retriever.setDataSource(videoUrl, HashMap()) + } + val bitmap = retriever.getFrameAtTime(0) + + // Save bitmap to cache if it was successfully retrieved + bitmap?.let { + saveBitmapToCache(context, videoUrl, it) + } + + return bitmap + } catch (e: Exception) { + // Log the exception for debugging but don't crash + e.printStackTrace() + return null + } finally { + try { + retriever.release() + } catch (e: Exception) { + // Ignore release exceptions + e.printStackTrace() + } + } + } + + private fun isLocalFile(url: String): Boolean { + return url.startsWith("/") || url.startsWith("file://") + } + + private fun getCacheFile(context: Context, videoUrl: String): File { + val cacheDir = context.cacheDir + val fileName = generateFileName(videoUrl) + return File(cacheDir, "video_thumbnails/$fileName") + } + + private fun generateFileName(videoUrl: String): String { + val md = MessageDigest.getInstance("MD5") + val digest = md.digest(videoUrl.toByteArray()) + return digest.joinToString("") { "%02x".format(it) } + ".jpg" + } + + private fun saveBitmapToCache(context: Context, videoUrl: String, bitmap: Bitmap) { + try { + val cacheFile = getCacheFile(context, videoUrl) + cacheFile.parentFile?.mkdirs() // Create directories if they don't exist + + FileOutputStream(cacheFile).use { out -> + bitmap.compress(Bitmap.CompressFormat.JPEG, 90, out) + } + } catch (e: Exception) { + e.printStackTrace() + } + } + + /** + * Clear the bitmap cache to free storage + */ + fun clearCache(context: Context) { + try { + val cacheDir = File(context.cacheDir, "video_thumbnails") + if (cacheDir.exists()) { + cacheDir.deleteRecursively() + } + } catch (e: Exception) { + e.printStackTrace() + } + } + + /** + * Remove a specific bitmap from cache + */ + fun removeFromCache(context: Context, videoUrl: String) { + try { + val cacheFile = getCacheFile(context, videoUrl) + if (cacheFile.exists()) { + cacheFile.delete() + } + } catch (e: Exception) { + e.printStackTrace() + } + } +} 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 b401d0eb4..572d4bc5c 100644 --- a/core/src/main/java/org/openedx/core/utils/TimeUtils.kt +++ b/core/src/main/java/org/openedx/core/utils/TimeUtils.kt @@ -77,6 +77,25 @@ object TimeUtils { } } } + fun formatToDueInString(context: Context, date: Date): String { + val now = Calendar.getInstance() + val dueDate = Calendar.getInstance().apply { time = date } + now.set(Calendar.HOUR_OF_DAY, 0) + now.set(Calendar.MINUTE, 0) + now.set(Calendar.SECOND, 0) + now.set(Calendar.MILLISECOND, 0) + dueDate.set(Calendar.HOUR_OF_DAY, 0) + dueDate.set(Calendar.MINUTE, 0) + dueDate.set(Calendar.SECOND, 0) + dueDate.set(Calendar.MILLISECOND, 0) + val daysDifference = + ((dueDate.timeInMillis - now.timeInMillis) / (24 * 60 * 60 * 1000)).toInt() + return when { + daysDifference < 0 -> context.getString(R.string.core_date_type_past_due) + daysDifference == 0 -> context.getString(R.string.core_date_type_today) + else -> context.getString(R.string.core_date_format_due_in_days, daysDifference) + } + } fun getCurrentTime(): Long { return Calendar.getInstance().timeInMillis diff --git a/core/src/main/res/drawable/core_ic_mountains.xml b/core/src/main/res/drawable/core_ic_mountains.xml new file mode 100644 index 000000000..eea9a0e6b --- /dev/null +++ b/core/src/main/res/drawable/core_ic_mountains.xml @@ -0,0 +1,34 @@ + + + + + + diff --git a/core/src/main/res/drawable/ic_core_check.xml b/core/src/main/res/drawable/ic_core_check.xml new file mode 100644 index 000000000..e636ca1d8 --- /dev/null +++ b/core/src/main/res/drawable/ic_core_check.xml @@ -0,0 +1,12 @@ + + + + diff --git a/core/src/main/res/drawable/ic_core_pointer.xml b/core/src/main/res/drawable/ic_core_pointer.xml new file mode 100644 index 000000000..cc777cf3e --- /dev/null +++ b/core/src/main/res/drawable/ic_core_pointer.xml @@ -0,0 +1,9 @@ + + + diff --git a/core/src/main/res/drawable/ic_core_watch_later.xml b/core/src/main/res/drawable/ic_core_watch_later.xml new file mode 100644 index 000000000..4dd7cedf0 --- /dev/null +++ b/core/src/main/res/drawable/ic_core_watch_later.xml @@ -0,0 +1,14 @@ + + + + diff --git a/core/src/main/res/values/strings.xml b/core/src/main/res/values/strings.xml index e28580acc..405751cf8 100644 --- a/core/src/main/res/values/strings.xml +++ b/core/src/main/res/values/strings.xml @@ -87,11 +87,13 @@ Completed Past Due Today + Due Tomorrow This Week Next Week Upcoming None Due %1$s + Due in %1$d days %d Item Hidden %d Items Hidden @@ -152,13 +154,13 @@ 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 - No course content is currently available. - There are currently no videos for this course. + No videos available for this course. Course dates are currently not available. This course does not contain exams or graded assignments. + No assignments available for this course. Unable to load discussions.\n Please try again later. There are currently no handouts for this course. There are currently no announcements for this course. @@ -182,7 +184,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 - %2$s - %3$d / %4$d Downloading this content requires an active internet connection. Please connect to the internet and try again. Wi-Fi Required Downloading this content requires an active WiFi connection. Please connect to a WiFi network and try again. @@ -213,7 +214,6 @@ Explore other parts of this course or view this when you reconnect. This component is not downloaded Explore other parts of this course or download this when you reconnect. - Authorization Please enter the system to continue with course enrollment. 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 65c082f70..df4f6c357 100644 --- a/core/src/openedx/org/openedx/core/ui/theme/Colors.kt +++ b/core/src/openedx/org/openedx/core/ui/theme/Colors.kt @@ -72,7 +72,7 @@ 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_color = light_success_green val light_progress_bar_background_color = Color(0xFFCCD4E0) val light_grade_progress_bar_color = Color.Black @@ -146,6 +146,6 @@ 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_color = dark_success_green val dark_progress_bar_background_color = Color(0xFF8E9BAE) val dark_grade_progress_bar_color = Color.Transparent diff --git a/core/src/openedx/org/openedx/core/ui/theme/LocalShapes.kt b/core/src/openedx/org/openedx/core/ui/theme/LocalShapes.kt index b5415bc5e..f126b44e3 100644 --- a/core/src/openedx/org/openedx/core/ui/theme/LocalShapes.kt +++ b/core/src/openedx/org/openedx/core/ui/theme/LocalShapes.kt @@ -20,6 +20,8 @@ internal val LocalShapes = staticCompositionLocalOf { cardShape = RoundedCornerShape(12.dp), screenBackgroundShapeFull = RoundedCornerShape(24.dp), courseImageShape = RoundedCornerShape(8.dp), - dialogShape = RoundedCornerShape(24.dp) + dialogShape = RoundedCornerShape(24.dp), + sectionCardShape = RoundedCornerShape(6.dp), + videoPreviewShape = RoundedCornerShape(8.dp), ) } diff --git a/course/src/main/java/org/openedx/course/data/repository/CourseRepository.kt b/course/src/main/java/org/openedx/course/data/repository/CourseRepository.kt index 914ce7191..2e460bfa6 100644 --- a/course/src/main/java/org/openedx/course/data/repository/CourseRepository.kt +++ b/course/src/main/java/org/openedx/course/data/repository/CourseRepository.kt @@ -7,6 +7,7 @@ import org.openedx.core.ApiConstants import org.openedx.core.data.api.CourseApi import org.openedx.core.data.model.BlocksCompletionBody import org.openedx.core.data.model.room.OfflineXBlockProgress +import org.openedx.core.data.model.room.VideoProgressEntity import org.openedx.core.data.model.room.XBlockProgressData import org.openedx.core.data.storage.CorePreferences import org.openedx.core.data.storage.CourseDao @@ -240,6 +241,21 @@ class CourseRepository( } } + suspend fun saveVideoProgress( + blockId: String, + videoUrl: String, + videoTime: Long, + duration: Long + ) { + val videoProgressEntity = VideoProgressEntity(blockId, videoUrl, videoTime, duration) + courseDao.insertVideoProgressEntity(videoProgressEntity) + } + + suspend fun getVideoProgress(blockId: String): VideoProgressEntity { + return courseDao.getVideoProgressByBlockId(blockId) + ?: VideoProgressEntity(blockId, "", 0L, 0L) + } + fun getCourseProgress(courseId: String, isRefresh: Boolean): Flow = channelFlowWithAwait { if (!isRefresh) { diff --git a/course/src/main/java/org/openedx/course/domain/interactor/CourseInteractor.kt b/course/src/main/java/org/openedx/course/domain/interactor/CourseInteractor.kt index 49fdf0d42..7da1623d7 100644 --- a/course/src/main/java/org/openedx/course/domain/interactor/CourseInteractor.kt +++ b/course/src/main/java/org/openedx/course/domain/interactor/CourseInteractor.kt @@ -122,4 +122,6 @@ class CourseInteractor( fun getCourseProgress(courseId: String, isRefresh: Boolean) = repository.getCourseProgress(courseId, isRefresh) + + suspend fun getVideoProgress(blockId: String) = repository.getVideoProgress(blockId) } diff --git a/course/src/main/java/org/openedx/course/presentation/CourseAnalytics.kt b/course/src/main/java/org/openedx/course/presentation/CourseAnalytics.kt index 0eff40583..99ff6d2e1 100644 --- a/course/src/main/java/org/openedx/course/presentation/CourseAnalytics.kt +++ b/course/src/main/java/org/openedx/course/presentation/CourseAnalytics.kt @@ -70,6 +70,14 @@ enum class CourseAnalyticsEvent(val eventName: String, val biValue: String) { "Course:Progress Tab", "edx.bi.app.course.progress_tab" ), + OFFLINE_TAB( + "Course:Offline Tab", + "edx.bi.app.course.offline_tab" + ), + CONTENT_TAB( + "Course:Content Tab", + "edx.bi.app.course.content_tab" + ), ANNOUNCEMENTS( "Course:Announcements", "edx.bi.app.course.announcements" @@ -82,6 +90,10 @@ enum class CourseAnalyticsEvent(val eventName: String, val biValue: String) { "Course:Unit Detail", "edx.bi.app.course.unit_detail" ), + COURSE_CONTENT_TAB_CLICK( + "Content Page:Section Click", + "edx.bi.app.course.content.section.clicked" + ), VIEW_CERTIFICATE( "Course:View Certificate Clicked", "edx.bi.app.course.view_certificate.clicked" @@ -114,6 +126,18 @@ enum class CourseAnalyticsEvent(val eventName: String, val biValue: String) { "Video:Completed", "edx.bi.app.videos.completed" ), + VIDEO_SHOW_COMPLETED( + "Content Page:Show Completed Subsection Click", + "edx.bi.app.course.content.show_completed_subsection.clicked" + ), + COURSE_CONTENT_VIDEO_CLICK( + "Course:Video Clicked", + "edx.bi.app.course.content.video.clicked" + ), + COURSE_CONTENT_ASSIGNMENT_CLICK( + "Course:Assignment click", + "edx.bi.app.course.content.assignment.clicked" + ), CAST_CONNECTED( "Cast:Connected", "edx.bi.app.cast.connected" @@ -150,6 +174,10 @@ enum class CourseAnalyticsEvent(val eventName: String, val biValue: String) { "Dates:CalendarSync Snackbar", "edx.bi.app.dates.calendar_sync.snackbar" ), + ASSIGNMENT_CLICKED( + "Course:Assignment Tab.Assignment Clicked", + "edx.bi.app.course.assignment_tab.assignment.clicked" + ), } enum class CourseAnalyticsKey(val key: String) { @@ -168,6 +196,7 @@ enum class CourseAnalyticsKey(val key: String) { LINK("link"), SUPPORTED("supported"), BLOCK_ID("block_id"), + TAB_NAME("tab_name"), BLOCK_NAME("block_name"), BLOCK_TYPE("block_type"), PLAY_MEDIUM("play_medium"), 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 1f874e055..d600b0897 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,6 @@ package org.openedx.course.presentation import androidx.fragment.app.FragmentManager import org.openedx.core.presentation.course.CourseViewMode -import org.openedx.core.presentation.settings.video.VideoQualityType import org.openedx.course.presentation.handouts.HandoutsType interface CourseRouter { @@ -63,7 +62,5 @@ interface CourseRouter { fun navigateToDownloadQueue(fm: FragmentManager, descendants: List = arrayListOf()) - fun navigateToVideoQuality(fm: FragmentManager, videoQualityType: VideoQualityType) - fun navigateToDiscover(fm: FragmentManager) } diff --git a/course/src/main/java/org/openedx/course/presentation/assignments/CourseAssignmentUIState.kt b/course/src/main/java/org/openedx/course/presentation/assignments/CourseAssignmentUIState.kt new file mode 100644 index 000000000..28da59f6d --- /dev/null +++ b/course/src/main/java/org/openedx/course/presentation/assignments/CourseAssignmentUIState.kt @@ -0,0 +1,16 @@ +package org.openedx.course.presentation.assignments + +import org.openedx.core.domain.model.Block +import org.openedx.core.domain.model.CourseProgress +import org.openedx.core.domain.model.Progress + +sealed class CourseAssignmentUIState { + data class CourseData( + val groupedAssignments: Map>, + val courseProgress: CourseProgress, + val progress: Progress, + val sectionNames: Map + ) : CourseAssignmentUIState() + data object Empty : CourseAssignmentUIState() + data object Loading : CourseAssignmentUIState() +} diff --git a/course/src/main/java/org/openedx/course/presentation/assignments/CourseAssignmentViewModel.kt b/course/src/main/java/org/openedx/course/presentation/assignments/CourseAssignmentViewModel.kt new file mode 100644 index 000000000..1e480e538 --- /dev/null +++ b/course/src/main/java/org/openedx/course/presentation/assignments/CourseAssignmentViewModel.kt @@ -0,0 +1,138 @@ +package org.openedx.course.presentation.assignments + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.catch +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.launch +import org.openedx.core.domain.model.Block +import org.openedx.core.domain.model.CourseProgress +import org.openedx.core.domain.model.CourseStructure +import org.openedx.core.domain.model.Progress +import org.openedx.core.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.CourseAnalyticsEvent +import org.openedx.course.presentation.CourseAnalyticsKey +import org.openedx.course.presentation.CourseRouter + +class CourseAssignmentViewModel( + val courseId: String, + val courseRouter: CourseRouter, + private val interactor: CourseInteractor, + private val courseNotifier: CourseNotifier, + private val analytics: CourseAnalytics, +) : ViewModel() { + private val _uiState = + MutableStateFlow(CourseAssignmentUIState.Loading) + val uiState: StateFlow = _uiState.asStateFlow() + + init { + collectCourseNotifier() + collectData() + } + + private fun collectData() { + viewModelScope.launch { + val courseProgressFlow = interactor.getCourseProgress(courseId, false) + val courseStructureFlow = interactor.getCourseStructureFlow(courseId) + + combine( + courseProgressFlow, + courseStructureFlow + ) { courseProgress, courseStructure -> + courseProgress to courseStructure + }.catch { + if (_uiState.value !is CourseAssignmentUIState.CourseData) { + _uiState.value = CourseAssignmentUIState.Empty + } + }.collect { (courseProgress, courseStructure) -> + if (courseStructure != null) { + updateAssignments(courseStructure, courseProgress) + } else { + _uiState.value = CourseAssignmentUIState.Empty + } + } + } + } + + private fun updateAssignments( + courseStructure: CourseStructure, + courseProgress: CourseProgress + ) { + val assignments = courseStructure.blockData + .filter { !it.assignmentProgress?.assignmentType.isNullOrEmpty() } + if (assignments.isEmpty()) { + _uiState.value = CourseAssignmentUIState.Empty + } else { + val assignmentTypeOrder = + courseProgress.gradingPolicy?.assignmentPolicies?.map { it.type } ?: emptyList() + val filteredAssignments = assignments + .filter { assignment -> + assignmentTypeOrder.contains(assignment.assignmentProgress?.assignmentType) + } + .filter { it.graded } + val grouped = filteredAssignments + .groupBy { it.assignmentProgress?.assignmentType ?: "" } + .toSortedMap(compareBy { assignmentTypeOrder.indexOf(it) }) + val completed = assignments.count { it.isCompleted() } + val total = assignments.size + val progress = Progress(completed, total) + val sectionName = + createAssignmentToChapterMapping(courseStructure.blockData, assignments) + _uiState.value = CourseAssignmentUIState.CourseData( + groupedAssignments = grouped, + courseProgress = courseProgress, + progress = progress, + sectionNames = sectionName + ) + } + } + + private fun collectCourseNotifier() { + viewModelScope.launch { + courseNotifier.notifier.collect { event -> + when (event) { + is CourseStructureUpdated -> collectData() + } + } + } + } + + fun logAssignmentClick(blockId: String) { + analytics.logEvent( + CourseAnalyticsEvent.COURSE_CONTENT_ASSIGNMENT_CLICK.eventName, + buildMap { + put( + CourseAnalyticsKey.NAME.key, + CourseAnalyticsEvent.COURSE_CONTENT_ASSIGNMENT_CLICK.biValue + ) + put(CourseAnalyticsKey.COURSE_ID.key, courseId) + put(CourseAnalyticsKey.BLOCK_ID.key, blockId) + } + ) + } + + private fun createAssignmentToChapterMapping( + allBlocks: List, + assignments: List + ): Map { + val assignmentToChapterMap = mutableMapOf() + assignments.forEach { assignment -> + val chapterBlock = findChapterForAssignment(assignment.id, allBlocks) + if (chapterBlock != null) { + assignmentToChapterMap[assignment.id] = chapterBlock.displayName + } + } + + return assignmentToChapterMap + } + + private fun findChapterForAssignment(assignmentId: String, blocks: List): Block? { + return blocks.firstOrNull { it.descendants.contains(assignmentId) } + } +} diff --git a/course/src/main/java/org/openedx/course/presentation/assignments/CourseContentAssignmentScreen.kt b/course/src/main/java/org/openedx/course/presentation/assignments/CourseContentAssignmentScreen.kt new file mode 100644 index 000000000..57d2d5766 --- /dev/null +++ b/course/src/main/java/org/openedx/course/presentation/assignments/CourseContentAssignmentScreen.kt @@ -0,0 +1,706 @@ +package org.openedx.course.presentation.assignments + +import androidx.compose.animation.animateContentSize +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.Image +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.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyRow +import androidx.compose.foundation.lazy.items +import androidx.compose.material.Card +import androidx.compose.material.CircularProgressIndicator +import androidx.compose.material.Divider +import androidx.compose.material.Icon +import androidx.compose.material.LinearProgressIndicator +import androidx.compose.material.MaterialTheme +import androidx.compose.material.Surface +import androidx.compose.material.Text +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowForward +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +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 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.CourseProgress +import org.openedx.core.domain.model.Progress +import org.openedx.core.presentation.course.CourseViewMode +import org.openedx.core.ui.theme.OpenEdXTheme +import org.openedx.core.ui.theme.appColors +import org.openedx.core.ui.theme.appShapes +import org.openedx.core.ui.theme.appTypography +import org.openedx.core.utils.TimeUtils +import org.openedx.course.R +import org.openedx.course.presentation.contenttab.CourseContentAssignmentEmptyState +import org.openedx.course.presentation.ui.CourseProgress +import org.openedx.foundation.presentation.WindowSize +import org.openedx.foundation.presentation.WindowType +import org.openedx.foundation.presentation.windowSizeValue +import java.util.Date +import org.openedx.core.R as coreR + +private const val ICON_SIZE_DP = 20 +private const val POINTER_ICON_SIZE_DP = 10 +private const val POINTER_ICON_PADDING_TOP_DP = 4 +private const val PROGRESS_HEIGHT_DP = 6 +private const val ASSIGNMENT_BUTTON_CARD_BACKGROUND_ALPHA = 0.5f +private const val COMPLETED_ASSIGNMENTS_COUNT = 1 +private const val COMPLETED_ASSIGNMENTS_COUNT_TABLET = 2 +private const val TOTAL_ASSIGNMENTS_COUNT = 3 + +@Composable +fun CourseContentAssignmentScreen( + windowSize: WindowSize, + viewModel: CourseAssignmentViewModel, + fragmentManager: FragmentManager, + onNavigateToHome: () -> Unit = {}, +) { + val uiState by viewModel.uiState.collectAsState() + CourseContentAssignmentScreen( + uiState = uiState, + windowSize = windowSize, + onNavigateToHome = onNavigateToHome, + onAssignmentClick = { subSectionBlock -> + viewModel.courseRouter.navigateToCourseSubsections( + fm = fragmentManager, + courseId = viewModel.courseId, + subSectionId = subSectionBlock.id, + mode = CourseViewMode.FULL + ) + viewModel.logAssignmentClick(subSectionBlock.id) + }, + ) +} + +@Composable +private fun CourseContentAssignmentScreen( + uiState: CourseAssignmentUIState, + windowSize: WindowSize, + onNavigateToHome: () -> Unit, + onAssignmentClick: (Block) -> Unit, +) { + val screenWidth by remember(key1 = windowSize) { + mutableStateOf( + windowSize.windowSizeValue( + expanded = Modifier.widthIn(Dp.Unspecified, 560.dp), + compact = Modifier.fillMaxWidth() + ) + ) + } + + when (uiState) { + is CourseAssignmentUIState.Loading -> { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center + ) { + CircularProgressIndicator() + } + } + + is CourseAssignmentUIState.Empty -> { + CourseContentAssignmentEmptyState( + onReturnToCourseClick = onNavigateToHome + ) + } + + is CourseAssignmentUIState.CourseData -> { + val gradingPolicy = uiState.courseProgress.gradingPolicy + val defaultGradeColor = MaterialTheme.appColors.primary + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.TopCenter + ) { + val progress = uiState.progress + val description = stringResource( + id = R.string.course_completed, + progress.completed, + progress.total + ) + LazyColumn( + modifier = screenWidth, + contentPadding = PaddingValues(bottom = 16.dp) + ) { + item { + Column { + CourseProgress( + modifier = Modifier.padding(horizontal = 24.dp), + progress = progress, + description = description + ) + Spacer(modifier = Modifier.padding(vertical = 6.dp)) + Divider( + color = MaterialTheme.appColors.divider + ) + Spacer(modifier = Modifier.padding(vertical = 4.dp)) + } + } + uiState.groupedAssignments.onEachIndexed { index, (type, blocks) -> + val percentOfGrade = gradingPolicy?.assignmentPolicies + ?.find { it.type == type } + ?.weight?.times(100) + ?.toInt() ?: 0 + val gradeColor = + if (gradingPolicy?.assignmentColors?.isNotEmpty() == true) { + gradingPolicy.assignmentColors[index % gradingPolicy.assignmentColors.size] + } else { + defaultGradeColor + } + item { + AssignmentGroupSection( + label = type, + percentOfGrade = percentOfGrade, + gradeColor = gradeColor, + assignments = blocks, + sectionNames = uiState.sectionNames, + onAssignmentClick = onAssignmentClick, + ) + } + } + } + } + } + } +} + +@Composable +private fun AssignmentGroupSection( + label: String, + assignments: List, + sectionNames: Map, + percentOfGrade: Int, + gradeColor: Color, + onAssignmentClick: (Block) -> Unit, +) { + val progress = Progress( + total = assignments.size, + completed = assignments.filter { it.isCompleted() }.size + ) + val description = stringResource( + id = R.string.course_completed, + progress.completed, + progress.total + ) + val firstUncompletedId = assignments.firstOrNull { !it.isCompleted() }?.id + var selectedId by rememberSaveable(label) { mutableStateOf(firstUncompletedId) } + var isCompletedShown by rememberSaveable { mutableStateOf(false) } + + Column( + modifier = Modifier + .animateContentSize() + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 24.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Text( + modifier = Modifier.weight(1f), + text = label, + style = MaterialTheme.appTypography.headlineSmall, + color = MaterialTheme.appColors.textDark, + ) + Surface( + modifier = Modifier.padding(start = 8.dp), + color = gradeColor.copy(alpha = 0.1f), + border = BorderStroke(1.dp, gradeColor), + shape = MaterialTheme.appShapes.material.small + ) { + Text( + modifier = Modifier.padding(4.dp), + text = stringResource(R.string.course_of_grade, percentOfGrade), + color = MaterialTheme.appColors.textDark, + style = MaterialTheme.appTypography.labelSmall, + maxLines = 1 + ) + } + } + Spacer(modifier = Modifier.padding(vertical = 12.dp)) + CourseProgress( + modifier = Modifier + .padding(horizontal = 24.dp), + progress = progress, + description = description, + isCompletedShown = isCompletedShown, + onVisibilityChanged = if (progress.value == 1f) { + { isCompletedShown = !isCompletedShown } + } else { + null + }, + ) + if (isCompletedShown || progress.value != 1f) { + if (assignments.size > 1) { + LazyRow( + horizontalArrangement = Arrangement.spacedBy(8.dp), + modifier = Modifier.padding(vertical = 8.dp), + contentPadding = PaddingValues(horizontal = 24.dp) + ) { + items(assignments) { assignment -> + AssignmentButton( + assignment = assignment, + isSelected = assignment.id == selectedId, + onClick = { + selectedId = if (selectedId == assignment.id) { + null + } else { + assignment.id + } + } + ) + } + } + } + if (assignments.size > 1) { + // Show details for selected assignment in this group + assignments.find { it.id == selectedId }?.let { assignment -> + AssignmentDetails( + modifier = Modifier + .padding(horizontal = 24.dp), + assignment = assignment, + sectionName = sectionNames[assignment.id] ?: "", + onAssignmentClick = onAssignmentClick + ) + } + } else { + val assignment = assignments.firstOrNull() ?: return@Column + AssignmentDetails( + modifier = Modifier + .padding(horizontal = 24.dp) + .padding(top = 8.dp), + assignment = assignment, + sectionName = sectionNames[assignment.id] ?: "", + onAssignmentClick = onAssignmentClick + ) + } + } + Divider( + modifier = Modifier.padding(vertical = 12.dp), + color = MaterialTheme.appColors.divider + ) + } +} + +@Composable +private fun AssignmentButton(assignment: Block, isSelected: Boolean, onClick: () -> Unit) { + val isDuePast = assignment.due != null && assignment.due!! < Date() + val cardBorderColor = when { + isSelected -> MaterialTheme.appColors.primary + assignment.isCompleted() -> MaterialTheme.appColors.successGreen + isDuePast -> MaterialTheme.appColors.warning + else -> MaterialTheme.appColors.textDark + } + val icon = when { + assignment.isCompleted() -> painterResource(id = coreR.drawable.ic_core_check) + isDuePast -> painterResource(id = coreR.drawable.ic_core_watch_later) + else -> null + } + val iconDescription = when { + assignment.isCompleted() -> stringResource(R.string.course_accessibility_assignment_completed) + isDuePast -> stringResource(R.string.course_accessibility_assignment_completed) + else -> null + } + val borderWidth = when { + isSelected -> 2.dp + else -> 1.dp + } + val cardBackground = when { + assignment.isCompleted() -> MaterialTheme.appColors.successGreen.copy( + ASSIGNMENT_BUTTON_CARD_BACKGROUND_ALPHA + ) + + isDuePast -> MaterialTheme.appColors.warning.copy(ASSIGNMENT_BUTTON_CARD_BACKGROUND_ALPHA) + else -> MaterialTheme.appColors.cardViewBackground + } + Column( + horizontalAlignment = Alignment.CenterHorizontally + ) { + Box( + contentAlignment = Alignment.TopCenter, + ) { + Card( + modifier = Modifier + .width(60.dp) + .height(42.dp) + .clickable { + onClick() + }, + backgroundColor = cardBackground, + shape = MaterialTheme.appShapes.material.small, + border = BorderStroke( + width = borderWidth, + color = cardBorderColor + ), + elevation = 0.dp, + ) { + Box( + modifier = Modifier + .fillMaxSize() + .padding(2.dp) + ) { + Text( + modifier = Modifier.align(Alignment.Center), + text = assignment.assignmentProgress?.label ?: "", + color = MaterialTheme.appColors.textDark, + style = MaterialTheme.appTypography.bodyMedium, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } + } + if (icon != null) { + Image( + modifier = Modifier + .size(16.dp) + .offset(y = (-6).dp), + painter = icon, + contentDescription = iconDescription, + ) + } + } + if (isSelected) { + Icon( + modifier = Modifier + .size(POINTER_ICON_SIZE_DP.dp) + .padding(top = POINTER_ICON_PADDING_TOP_DP.dp), + painter = painterResource(id = coreR.drawable.ic_core_pointer), + tint = MaterialTheme.appColors.primary, + contentDescription = null + ) + } else { + Box( + modifier = Modifier + .size(POINTER_ICON_SIZE_DP.dp) + .padding(top = POINTER_ICON_PADDING_TOP_DP.dp) + ) + } + } +} + +@Composable +private fun AssignmentDetails( + modifier: Modifier = Modifier, + assignment: Block, + sectionName: String, + onAssignmentClick: (Block) -> Unit, +) { + val dueDate = + assignment.due?.let { + TimeUtils.formatToDueInString(LocalContext.current, it) + } ?: "" + val isDuePast = assignment.due != null && assignment.due!! < Date() + val progress = assignment.completion.toFloat() + val color = when { + assignment.isCompleted() -> MaterialTheme.appColors.successGreen + isDuePast -> MaterialTheme.appColors.warning + else -> MaterialTheme.appColors.cardViewBorder + } + val label = assignment.assignmentProgress?.label + val description = when { + assignment.isCompleted() -> { + "$label " + stringResource( + R.string.course_complete_points, + assignment.assignmentProgress?.toPointString() ?: "" + ) + } + + isDuePast -> { + "$label " + stringResource( + R.string.course_past_due, + assignment.assignmentProgress?.toPointString() ?: "" + ) + } + + progress < 1f && assignment.due == null -> { + "$label " + stringResource( + R.string.course_in_progress, + assignment.assignmentProgress?.toPointString() ?: "" + ) + } + + else -> { + "$label $dueDate" + } + } + Card( + modifier = modifier + .fillMaxWidth() + .clickable { + onAssignmentClick(assignment) + }, + backgroundColor = MaterialTheme.appColors.cardViewBackground, + shape = MaterialTheme.appShapes.material.small, + border = BorderStroke( + width = 1.dp, + color = color + ), + elevation = 0.dp, + ) { + Column { + LinearProgressIndicator( + modifier = Modifier + .fillMaxWidth() + .height(PROGRESS_HEIGHT_DP.dp), + progress = progress, + color = MaterialTheme.appColors.progressBarColor, + backgroundColor = color + ) + Row( + modifier = Modifier + .fillMaxWidth() + .padding(12.dp), + verticalAlignment = Alignment.Bottom, + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Column { + Text( + text = sectionName, + style = MaterialTheme.appTypography.bodySmall, + color = MaterialTheme.appColors.textDark + ) + Text( + text = assignment.displayName, + style = MaterialTheme.appTypography.bodyLarge, + color = MaterialTheme.appColors.textDark + ) + if (description.isNotEmpty()) { + Text( + text = description, + style = MaterialTheme.appTypography.bodySmall, + color = MaterialTheme.appColors.textDark + ) + } + } + Icon( + modifier = Modifier + .size(ICON_SIZE_DP.dp), + imageVector = Icons.AutoMirrored.Filled.ArrowForward, + contentDescription = null, + tint = MaterialTheme.appColors.primary + ) + } + } + } +} + +@Preview +@Composable +private fun CourseContentAssignmentScreenPreview() { + OpenEdXTheme { + CourseContentAssignmentScreen( + windowSize = WindowSize(WindowType.Compact, WindowType.Compact), + uiState = CourseAssignmentUIState.CourseData( + progress = Progress(COMPLETED_ASSIGNMENTS_COUNT, TOTAL_ASSIGNMENTS_COUNT), + groupedAssignments = mapOf( + "Homework" to listOf(mockChapterBlock, mockSequentialBlock) + ), + courseProgress = mockCourseProgress, + sectionNames = mapOf() + ), + onAssignmentClick = {}, + onNavigateToHome = {}, + ) + } +} + +@Preview +@Composable +private fun CourseContentAssignmentScreenEmptyPreview() { + OpenEdXTheme { + CourseContentAssignmentScreen( + windowSize = WindowSize(WindowType.Compact, WindowType.Compact), + uiState = CourseAssignmentUIState.Empty, + onAssignmentClick = {}, + onNavigateToHome = {}, + ) + } +} + +@Preview(device = Devices.NEXUS_9) +@Composable +private fun CourseContentAssignmentScreenTabletPreview() { + OpenEdXTheme { + CourseContentAssignmentScreen( + windowSize = WindowSize(WindowType.Medium, WindowType.Medium), + uiState = CourseAssignmentUIState.CourseData( + progress = Progress(COMPLETED_ASSIGNMENTS_COUNT_TABLET, TOTAL_ASSIGNMENTS_COUNT), + groupedAssignments = mapOf( + "Homework" to listOf(mockChapterBlock), + "Quiz" to listOf(mockSequentialBlock) + ), + courseProgress = mockCourseProgress, + sectionNames = mapOf() + ), + onAssignmentClick = {}, + onNavigateToHome = {}, + ) + } +} + +private val mockCourseProgress = CourseProgress( + verifiedMode = "verified", + accessExpiration = "2024-12-31", + certificateData = CourseProgress.CertificateData( + certStatus = "downloadable", + certWebViewUrl = "https://example.com/cert", + downloadUrl = "https://example.com/cert.pdf", + certificateAvailableDate = "2024-06-01" + ), + completionSummary = CourseProgress.CompletionSummary( + completeCount = 5, + incompleteCount = 3, + lockedCount = 1 + ), + courseGrade = CourseProgress.CourseGrade( + letterGrade = "B+", + percent = 85.5, + isPassing = true + ), + creditCourseRequirements = "Complete all assignments", + end = "2024-12-31", + enrollmentMode = "verified", + gradingPolicy = CourseProgress.GradingPolicy( + assignmentPolicies = listOf( + CourseProgress.GradingPolicy.AssignmentPolicy( + numDroppable = 1, + numTotal = 5, + shortLabel = "HW", + type = "Homework", + weight = 0.4 + ), + CourseProgress.GradingPolicy.AssignmentPolicy( + numDroppable = 0, + numTotal = 3, + shortLabel = "Quiz", + type = "Quiz", + weight = 0.6 + ) + ), + gradeRange = mapOf( + "A" to 0.9f, + "B" to 0.8f, + "C" to 0.7f, + "D" to 0.6f + ), + assignmentColors = listOf(Color(0xFF2196F3), Color(0xFF4CAF50)) + ), + hasScheduledContent = false, + sectionScores = listOf( + CourseProgress.SectionScore( + displayName = "Week 1", + subsections = listOf( + CourseProgress.SectionScore.Subsection( + assignmentType = "Homework", + blockKey = "block1", + displayName = "Homework 1", + hasGradedAssignment = true, + override = "", + learnerHasAccess = true, + numPointsEarned = 8f, + numPointsPossible = 10f, + percentGraded = 80.0, + problemScores = listOf( + CourseProgress.SectionScore.Subsection.ProblemScore( + earned = 8.0, + possible = 10.0 + ) + ), + showCorrectness = "always", + showGrades = true, + url = "https://example.com/hw1" + ) + ) + ) + ), + studioUrl = "https://studio.example.com", + username = "testuser", + userHasPassingGrade = true, + verificationData = CourseProgress.VerificationData( + link = "https://example.com/verify", + status = "approved", + statusDate = "2024-01-15" + ), + disableProgressGraph = false +) + +private val mockAssignmentProgress = AssignmentProgress( + assignmentType = "Home", + numPointsEarned = 1f, + numPointsPossible = 3f, + shortLabel = "HM1" +) + +private val mockChapterBlock = Block( + id = "id", + blockId = "blockId", + lmsWebUrl = "lmsWebUrl", + legacyWebUrl = "legacyWebUrl", + studentViewUrl = "studentViewUrl", + type = BlockType.CHAPTER, + displayName = "Chapter", + graded = false, + studentViewData = null, + studentViewMultiDevice = false, + blockCounts = BlockCounts(1), + descendants = emptyList(), + descendantsType = BlockType.CHAPTER, + completion = 0.0, + containsGatedContent = false, + assignmentProgress = mockAssignmentProgress, + due = Date(), + offlineDownload = null +) + +private val mockSequentialBlock = Block( + id = "id", + blockId = "blockId", + lmsWebUrl = "lmsWebUrl", + legacyWebUrl = "legacyWebUrl", + studentViewUrl = "studentViewUrl", + type = BlockType.SEQUENTIAL, + displayName = "Sequential", + graded = false, + studentViewData = null, + studentViewMultiDevice = false, + blockCounts = BlockCounts(1), + descendants = emptyList(), + descendantsType = BlockType.SEQUENTIAL, + completion = 0.0, + containsGatedContent = false, + assignmentProgress = mockAssignmentProgress, + due = Date(), + offlineDownload = null +) 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 a71d954df..6280cd2fb 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 @@ -24,6 +24,7 @@ import androidx.compose.foundation.pager.PagerState import androidx.compose.foundation.pager.rememberPagerState import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll +import androidx.compose.material.Divider import androidx.compose.material.ExperimentalMaterialApi import androidx.compose.material.MaterialTheme import androidx.compose.material.Scaffold @@ -32,6 +33,7 @@ import androidx.compose.material.SnackbarDuration import androidx.compose.material.SnackbarHost import androidx.compose.material.SnackbarHostState import androidx.compose.material.Text +import androidx.compose.material.TextButton import androidx.compose.material.pullrefresh.PullRefreshIndicator import androidx.compose.material.pullrefresh.pullRefresh import androidx.compose.material.pullrefresh.rememberPullRefreshState @@ -72,6 +74,7 @@ import org.openedx.core.domain.model.CourseAccessError import org.openedx.core.extension.isFalse import org.openedx.core.presentation.global.viewBinding import org.openedx.core.ui.HandleUIMessage +import org.openedx.core.ui.IconText import org.openedx.core.ui.OfflineModeDialog import org.openedx.core.ui.OpenEdXButton import org.openedx.core.ui.RoundTabsBar @@ -83,19 +86,19 @@ import org.openedx.core.utils.TimeUtils import org.openedx.course.DatesShiftedSnackBar import org.openedx.course.R import org.openedx.course.databinding.FragmentCourseContainerBinding +import org.openedx.course.presentation.contenttab.ContentTabScreen import org.openedx.course.presentation.dates.CourseDatesScreen import org.openedx.course.presentation.handouts.HandoutsScreen import org.openedx.course.presentation.handouts.HandoutsType import org.openedx.course.presentation.offline.CourseOfflineScreen -import org.openedx.course.presentation.outline.CourseOutlineScreen import org.openedx.course.presentation.progress.CourseProgressScreen -import org.openedx.course.presentation.ui.CourseVideosScreen import org.openedx.course.presentation.ui.DatesShiftedSnackBar import org.openedx.discussion.presentation.topics.DiscussionTopicsScreen import org.openedx.foundation.extension.takeIfNotEmpty import org.openedx.foundation.presentation.WindowSize import org.openedx.foundation.presentation.rememberWindowSize import java.util.Date +import org.openedx.core.R as coreR class CourseContainerFragment : Fragment(R.layout.fragment_course_container) { @@ -249,6 +252,35 @@ fun CourseDashboard( fragmentManager: FragmentManager, onRefresh: (page: Int) -> Unit, ) { + val refreshing by viewModel.refreshing.collectAsState(true) + val courseImage by viewModel.courseImage.collectAsState() + val uiMessage by viewModel.uiMessage.collectAsState(null) + val requiredTab = when (openTab.uppercase()) { + CourseContainerTab.HOME.name -> CourseContainerTab.HOME + CourseContainerTab.DATES.name -> CourseContainerTab.DATES + CourseContainerTab.DISCUSSIONS.name -> CourseContainerTab.DISCUSSIONS + CourseContainerTab.PROGRESS.name -> CourseContainerTab.PROGRESS + CourseContainerTab.MORE.name -> CourseContainerTab.MORE + else -> CourseContainerTab.HOME + } + + val pagerState = rememberPagerState( + initialPage = CourseContainerTab.entries.indexOf(requiredTab), + pageCount = { CourseContainerTab.entries.size } + ) + val contentTabPagerState = rememberPagerState( + initialPage = 0, + pageCount = { CourseContentTab.entries.size } + ) + val accessStatus = viewModel.courseAccessStatus.observeAsState() + val tabState = rememberLazyListState() + val snackState = remember { SnackbarHostState() } + var selectedContentTab by remember { mutableStateOf(CourseContentTab.ALL) } + val pullRefreshState = rememberPullRefreshState( + refreshing = refreshing, + onRefresh = { onRefresh(pagerState.currentPage) } + ) + OpenEdXTheme { val windowSize = rememberWindowSize() val scope = rememberCoroutineScope() @@ -258,32 +290,51 @@ fun CourseDashboard( .fillMaxSize() .navigationBarsPadding(), scaffoldState = scaffoldState, - backgroundColor = MaterialTheme.appColors.background - ) { paddingValues -> - val refreshing by viewModel.refreshing.collectAsState(true) - val courseImage by viewModel.courseImage.collectAsState() - val uiMessage by viewModel.uiMessage.collectAsState(null) - 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.PROGRESS.name -> CourseContainerTab.PROGRESS - CourseContainerTab.MORE.name -> CourseContainerTab.MORE - else -> CourseContainerTab.HOME + backgroundColor = MaterialTheme.appColors.background, + bottomBar = { + Box { + if (CourseContainerTab.entries[pagerState.currentPage] == CourseContainerTab.CONTENT && + selectedContentTab == CourseContentTab.ASSIGNMENTS + ) { + Column( + modifier = Modifier.background(MaterialTheme.appColors.background), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Divider(modifier = Modifier.fillMaxWidth()) + TextButton( + onClick = { + scrollToProgress(scope, pagerState) + } + ) { + IconText( + text = stringResource(R.string.course_review_grading_policy), + painter = painterResource(id = coreR.drawable.core_ic_mountains), + color = MaterialTheme.appColors.primary, + textStyle = MaterialTheme.appTypography.labelLarge + ) + } + } + } + var isInternetConnectionShown by rememberSaveable { + mutableStateOf(false) + } + if (!isInternetConnectionShown && !viewModel.hasInternetConnection) { + OfflineModeDialog( + Modifier + .fillMaxWidth() + .align(Alignment.BottomCenter), + onDismissCLick = { + isInternetConnectionShown = true + }, + onReloadClick = { + isInternetConnectionShown = viewModel.hasInternetConnection + onRefresh(pagerState.currentPage) + } + ) + } + } } - - val pagerState = rememberPagerState( - initialPage = CourseContainerTab.entries.indexOf(requiredTab), - pageCount = { CourseContainerTab.entries.size } - ) - val accessStatus = viewModel.courseAccessStatus.observeAsState() - val tabState = rememberLazyListState() - val snackState = remember { SnackbarHostState() } - val pullRefreshState = rememberPullRefreshState( - refreshing = refreshing, - onRefresh = { onRefresh(pagerState.currentPage) } - ) + ) { paddingValues -> if (uiMessage is DatesShiftedSnackBar) { val datesShiftedMessage = stringResource(id = R.string.course_dates_shifted_message) LaunchedEffect(uiMessage) { @@ -359,14 +410,16 @@ fun CourseDashboard( windowSize = windowSize, viewModel = viewModel, pagerState = pagerState, - isNavigationEnabled = isNavigationEnabled, + contentTabPagerState = contentTabPagerState, isResumed = isResumed, fragmentManager = fragmentManager, + onContentTabSelected = { tab -> + selectedContentTab = tab + } ) } - else -> { - } + else -> {} } } ) @@ -376,24 +429,6 @@ fun CourseDashboard( Modifier.align(Alignment.TopCenter) ) - var isInternetConnectionShown by rememberSaveable { - mutableStateOf(false) - } - if (!isInternetConnectionShown && !viewModel.hasInternetConnection) { - OfflineModeDialog( - Modifier - .fillMaxWidth() - .align(Alignment.BottomCenter), - onDismissCLick = { - isInternetConnectionShown = true - }, - onReloadClick = { - isInternetConnectionShown = viewModel.hasInternetConnection - onRefresh(pagerState.currentPage) - } - ) - } - SnackbarHost( modifier = Modifier.align(Alignment.BottomStart), hostState = snackState @@ -420,37 +455,21 @@ private fun DashboardPager( windowSize: WindowSize, viewModel: CourseContainerViewModel, pagerState: PagerState, - isNavigationEnabled: Boolean, + contentTabPagerState: PagerState, isResumed: Boolean, fragmentManager: FragmentManager, + onContentTabSelected: (CourseContentTab) -> Unit, ) { + val scope = rememberCoroutineScope() + HorizontalPager( state = pagerState, - userScrollEnabled = isNavigationEnabled, + userScrollEnabled = false, beyondViewportPageCount = CourseContainerTab.entries.size ) { page -> when (CourseContainerTab.entries[page]) { CourseContainerTab.HOME -> { - CourseOutlineScreen( - windowSize = windowSize, - viewModel = koinViewModel( - parameters = { parametersOf(viewModel.courseId, viewModel.courseName) } - ), - fragmentManager = fragmentManager, - onResetDatesClick = { - viewModel.onRefresh(CourseContainerTab.DATES) - } - ) - } - - CourseContainerTab.VIDEOS -> { - CourseVideosScreen( - windowSize = windowSize, - viewModel = koinViewModel( - parameters = { parametersOf(viewModel.courseId, viewModel.courseName) } - ), - fragmentManager = fragmentManager - ) + // Home tab content will be implemented later } CourseContainerTab.DATES -> { @@ -519,6 +538,29 @@ private fun DashboardPager( } ) } + + CourseContainerTab.CONTENT -> { + ContentTabScreen( + viewModel = koinViewModel( + parameters = { parametersOf(viewModel.courseId, viewModel.courseName) } + ), + windowSize = windowSize, + fragmentManager = fragmentManager, + courseId = viewModel.courseId, + courseName = viewModel.courseName, + pagerState = contentTabPagerState, + onTabSelected = onContentTabSelected, + onNavigateToHome = { + scope.launch { + pagerState.animateScrollToPage( + CourseContainerTab.entries.indexOf( + CourseContainerTab.HOME + ) + ) + } + } + ) + } } } } @@ -642,3 +684,10 @@ private fun scrollToDates(scope: CoroutineScope, pagerState: PagerState) { pagerState.animateScrollToPage(CourseContainerTab.entries.indexOf(CourseContainerTab.DATES)) } } + +@OptIn(ExperimentalFoundationApi::class) +private fun scrollToProgress(scope: CoroutineScope, pagerState: PagerState) { + scope.launch { + pagerState.animateScrollToPage(CourseContainerTab.entries.indexOf(CourseContainerTab.PROGRESS)) + } +} diff --git a/course/src/main/java/org/openedx/course/presentation/container/CourseContainerTab.kt b/course/src/main/java/org/openedx/course/presentation/container/CourseContainerTab.kt index 236c548f6..f7abc1981 100644 --- a/course/src/main/java/org/openedx/course/presentation/container/CourseContainerTab.kt +++ b/course/src/main/java/org/openedx/course/presentation/container/CourseContainerTab.kt @@ -3,12 +3,12 @@ package org.openedx.course.presentation.container import androidx.annotation.StringRes import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.Chat +import androidx.compose.material.icons.automirrored.filled.List import androidx.compose.material.icons.automirrored.filled.TextSnippet import androidx.compose.material.icons.filled.CloudDownload import androidx.compose.material.icons.filled.Home import androidx.compose.material.icons.filled.Moving import androidx.compose.material.icons.outlined.CalendarMonth -import androidx.compose.material.icons.rounded.PlayCircleFilled import androidx.compose.ui.graphics.vector.ImageVector import org.openedx.core.ui.TabItem import org.openedx.course.R @@ -19,10 +19,19 @@ enum class CourseContainerTab( override val icon: ImageVector, ) : TabItem { HOME(R.string.course_container_nav_home, Icons.Default.Home), - VIDEOS(R.string.course_container_nav_videos, Icons.Rounded.PlayCircleFilled), + CONTENT(R.string.course_container_nav_content, Icons.AutoMirrored.Filled.List), PROGRESS(R.string.course_container_nav_progress, Icons.Default.Moving), DATES(R.string.course_container_nav_dates, Icons.Outlined.CalendarMonth), OFFLINE(R.string.course_container_nav_downloads, Icons.Filled.CloudDownload), DISCUSSIONS(R.string.course_container_nav_discussions, Icons.AutoMirrored.Filled.Chat), MORE(R.string.course_container_nav_more, Icons.AutoMirrored.Filled.TextSnippet), } + +enum class CourseContentTab( + @StringRes + val labelResId: Int +) { + ALL(R.string.course_container_content_tab_all), + VIDEOS(R.string.course_container_content_tab_video), + ASSIGNMENTS(R.string.course_container_content_tab_assignment) +} 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 18f5f9b3c..ff9643bd4 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 @@ -284,7 +284,7 @@ class CourseContainerViewModel( updateData() } - CourseContainerTab.VIDEOS -> { + CourseContainerTab.CONTENT -> { updateData() } @@ -332,12 +332,12 @@ class CourseContainerViewModel( fun courseContainerTabClickedEvent(index: Int) { when (CourseContainerTab.entries[index]) { CourseContainerTab.HOME -> courseTabClickedEvent() - CourseContainerTab.VIDEOS -> videoTabClickedEvent() CourseContainerTab.DISCUSSIONS -> discussionTabClickedEvent() CourseContainerTab.DATES -> datesTabClickedEvent() CourseContainerTab.PROGRESS -> progressTabClickedEvent() CourseContainerTab.MORE -> moreTabClickedEvent() - CourseContainerTab.OFFLINE -> {} + CourseContainerTab.OFFLINE -> offlineTabClickedEvent() + CourseContainerTab.CONTENT -> contentTabClickedEvent() } } @@ -373,10 +373,6 @@ class CourseContainerViewModel( logCourseContainerEvent(CourseAnalyticsEvent.HOME_TAB) } - private fun videoTabClickedEvent() { - logCourseContainerEvent(CourseAnalyticsEvent.VIDEOS_TAB) - } - private fun discussionTabClickedEvent() { logCourseContainerEvent(CourseAnalyticsEvent.DISCUSSION_TAB) } @@ -393,6 +389,13 @@ class CourseContainerViewModel( logCourseContainerEvent(CourseAnalyticsEvent.PROGRESS_TAB) } + private fun offlineTabClickedEvent() { + logCourseContainerEvent(CourseAnalyticsEvent.OFFLINE_TAB) + } + + private fun contentTabClickedEvent() { + logCourseContainerEvent(CourseAnalyticsEvent.CONTENT_TAB) + } private fun logCourseContainerEvent(event: CourseAnalyticsEvent) { courseAnalytics.logScreenEvent( screenName = event.eventName, diff --git a/course/src/main/java/org/openedx/course/presentation/contenttab/ContentTabEmptyState.kt b/course/src/main/java/org/openedx/course/presentation/contenttab/ContentTabEmptyState.kt new file mode 100644 index 000000000..e5926b315 --- /dev/null +++ b/course/src/main/java/org/openedx/course/presentation/contenttab/ContentTabEmptyState.kt @@ -0,0 +1,122 @@ +package org.openedx.course.presentation.contenttab + +import android.content.res.Configuration +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +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.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.Icon +import androidx.compose.material.MaterialTheme +import androidx.compose.material.Text +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalConfiguration +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import org.openedx.core.ui.IconText +import org.openedx.core.ui.OpenEdXButton +import org.openedx.core.ui.theme.OpenEdXTheme +import org.openedx.core.ui.theme.appColors +import org.openedx.core.ui.theme.appTypography +import org.openedx.course.R + +@Composable +fun ContentTabEmptyState( + message: String, + onReturnToCourseClick: () -> Unit +) { + val configuration = LocalConfiguration.current + Column( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 24.dp) + .verticalScroll(rememberScrollState()), + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally + ) { + if (configuration.orientation == Configuration.ORIENTATION_PORTRAIT) { + Icon( + modifier = Modifier + .size(120.dp), + painter = painterResource(R.drawable.course_ic_warning), + contentDescription = null, + tint = MaterialTheme.appColors.textFieldHint + ) + Spacer(Modifier.height(24.dp)) + } + Text( + modifier = Modifier.padding(horizontal = 24.dp), + text = message, + color = MaterialTheme.appColors.textPrimary, + style = MaterialTheme.appTypography.bodyLarge, + fontWeight = FontWeight.Medium, + textAlign = TextAlign.Center + ) + Spacer(Modifier.height(16.dp)) + OpenEdXButton( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 24.dp), + textColor = MaterialTheme.appColors.secondaryButtonText, + backgroundColor = MaterialTheme.appColors.secondaryButtonBackground, + onClick = onReturnToCourseClick + ) { + IconText( + text = stringResource(id = R.string.course_return_to_course_home), + icon = Icons.AutoMirrored.Filled.ArrowBack, + color = MaterialTheme.appColors.secondaryButtonText, + textStyle = MaterialTheme.appTypography.labelLarge + ) + } + } +} + +@Composable +fun CourseContentAllEmptyState( + onReturnToCourseClick: () -> Unit +) { + ContentTabEmptyState( + message = stringResource(id = org.openedx.core.R.string.core_no_course_content), + onReturnToCourseClick = onReturnToCourseClick + ) +} + +@Composable +fun CourseContentVideoEmptyState( + onReturnToCourseClick: () -> Unit +) { + ContentTabEmptyState( + message = stringResource(id = org.openedx.core.R.string.core_no_videos), + onReturnToCourseClick = onReturnToCourseClick + ) +} + +@Composable +fun CourseContentAssignmentEmptyState( + onReturnToCourseClick: () -> Unit +) { + ContentTabEmptyState( + message = stringResource(id = org.openedx.core.R.string.core_no_assignments), + onReturnToCourseClick = onReturnToCourseClick + ) +} + +@Preview +@Composable +private fun CourseContentAllEmptyStatePreview() { + OpenEdXTheme { + CourseContentAllEmptyState({}) + } +} diff --git a/course/src/main/java/org/openedx/course/presentation/contenttab/ContentTabScreen.kt b/course/src/main/java/org/openedx/course/presentation/contenttab/ContentTabScreen.kt new file mode 100644 index 000000000..ad648da36 --- /dev/null +++ b/course/src/main/java/org/openedx/course/presentation/contenttab/ContentTabScreen.kt @@ -0,0 +1,189 @@ +package org.openedx.course.presentation.contenttab + +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.fillMaxHeight +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.width +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.pager.HorizontalPager +import androidx.compose.foundation.pager.PagerState +import androidx.compose.material.Divider +import androidx.compose.material.MaterialTheme +import androidx.compose.material.Scaffold +import androidx.compose.material.Text +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.rememberCoroutineScope +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import androidx.fragment.app.FragmentManager +import kotlinx.coroutines.launch +import org.koin.androidx.compose.koinViewModel +import org.koin.core.parameter.parametersOf +import org.openedx.core.ui.theme.appColors +import org.openedx.core.ui.theme.appShapes +import org.openedx.course.presentation.assignments.CourseContentAssignmentScreen +import org.openedx.course.presentation.container.CourseContentTab +import org.openedx.course.presentation.outline.CourseContentAllScreen +import org.openedx.course.presentation.videos.CourseContentVideoScreen +import org.openedx.foundation.presentation.WindowSize +import org.openedx.foundation.presentation.windowSizeValue + +@Composable +fun ContentTabScreen( + viewModel: ContentTabViewModel, + windowSize: WindowSize, + fragmentManager: FragmentManager, + courseId: String, + courseName: String, + pagerState: PagerState, + onTabSelected: (CourseContentTab) -> Unit = {}, + onNavigateToHome: () -> Unit = {}, +) { + val tabsWidth by remember(key1 = windowSize) { + mutableStateOf( + windowSize.windowSizeValue( + expanded = Modifier.widthIn(Dp.Unspecified, 560.dp), + compact = Modifier.fillMaxWidth() + ) + ) + } + val scope = rememberCoroutineScope() + + LaunchedEffect(pagerState.currentPage) { + val selectedTab = CourseContentTab.entries[pagerState.currentPage] + onTabSelected(selectedTab) + } + + Scaffold( + modifier = Modifier.fillMaxSize(), + ) { + Column( + modifier = Modifier + .fillMaxSize() + .padding(it), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Row( + modifier = Modifier + .padding(16.dp) + .then(tabsWidth) + .height(40.dp) + .clip(MaterialTheme.appShapes.buttonShape) + .border( + 1.dp, + MaterialTheme.appColors.primary, + MaterialTheme.appShapes.buttonShape + ), + horizontalArrangement = Arrangement.Center + ) { + CourseContentTab.entries.forEachIndexed { index, tab -> + val isSelected = pagerState.currentPage == index + val isEdgeItem = index == 0 || index == CourseContentTab.entries.size - 1 + Box( + modifier = Modifier + .background( + if (isSelected) { + MaterialTheme.appColors.primary + } else { + MaterialTheme.appColors.background + } + ) + .weight(1f) + .fillMaxHeight() + .clickable { + scope.launch { + pagerState.scrollToPage(index) + } + viewModel.logTabClickEvent(CourseContentTab.entries[index]) + }, + contentAlignment = Alignment.Center + ) { + if (!isEdgeItem) { + Divider( + modifier = Modifier + .fillMaxHeight() + .width(1.dp) + .align(Alignment.CenterStart), + color = MaterialTheme.appColors.primary + ) + } + Text( + text = stringResource(tab.labelResId), + color = if (isSelected) { + MaterialTheme.appColors.primaryButtonText + } else { + MaterialTheme.appColors.primary + }, + style = MaterialTheme.typography.button + ) + if (!isEdgeItem) { + Divider( + modifier = Modifier + .fillMaxHeight() + .width(1.dp) + .align(Alignment.CenterEnd), + color = MaterialTheme.appColors.primary + ) + } + } + } + } + + HorizontalPager( + state = pagerState, + userScrollEnabled = false, + beyondViewportPageCount = CourseContentTab.entries.size + ) { page -> + when (CourseContentTab.entries[page]) { + CourseContentTab.ALL -> CourseContentAllScreen( + windowSize = windowSize, + viewModel = koinViewModel(parameters = { + parametersOf( + courseId, + courseName + ) + }), + fragmentManager = fragmentManager, + onNavigateToHome = onNavigateToHome + ) + + CourseContentTab.VIDEOS -> CourseContentVideoScreen( + windowSize = windowSize, + viewModel = koinViewModel(parameters = { + parametersOf( + courseId, + courseName + ) + }), + fragmentManager = fragmentManager, + onNavigateToHome = onNavigateToHome + ) + + CourseContentTab.ASSIGNMENTS -> CourseContentAssignmentScreen( + windowSize = windowSize, + viewModel = koinViewModel(parameters = { parametersOf(courseId) }), + fragmentManager = fragmentManager, + onNavigateToHome = onNavigateToHome + ) + } + } + } + } +} diff --git a/course/src/main/java/org/openedx/course/presentation/contenttab/ContentTabViewModel.kt b/course/src/main/java/org/openedx/course/presentation/contenttab/ContentTabViewModel.kt new file mode 100644 index 000000000..7aebe86f3 --- /dev/null +++ b/course/src/main/java/org/openedx/course/presentation/contenttab/ContentTabViewModel.kt @@ -0,0 +1,29 @@ +package org.openedx.course.presentation.contenttab + +import org.openedx.course.presentation.CourseAnalytics +import org.openedx.course.presentation.CourseAnalyticsEvent +import org.openedx.course.presentation.CourseAnalyticsKey +import org.openedx.course.presentation.container.CourseContentTab +import org.openedx.foundation.presentation.BaseViewModel + +class ContentTabViewModel( + val courseId: String, + private val courseTitle: String, + private val analytics: CourseAnalytics, +) : BaseViewModel() { + + fun logTabClickEvent(contentTab: CourseContentTab) { + analytics.logEvent( + CourseAnalyticsEvent.COURSE_CONTENT_TAB_CLICK.eventName, + buildMap { + put( + CourseAnalyticsKey.NAME.key, + CourseAnalyticsEvent.COURSE_CONTENT_TAB_CLICK.biValue + ) + put(CourseAnalyticsKey.COURSE_ID.key, courseId) + put(CourseAnalyticsKey.COURSE_NAME.key, courseTitle) + put(CourseAnalyticsKey.TAB_NAME.key, contentTab.name) + } + ) + } +} diff --git a/course/src/main/java/org/openedx/course/presentation/outline/CourseOutlineScreen.kt b/course/src/main/java/org/openedx/course/presentation/outline/CourseContentAllScreen.kt similarity index 70% rename from course/src/main/java/org/openedx/course/presentation/outline/CourseOutlineScreen.kt rename to course/src/main/java/org/openedx/course/presentation/outline/CourseContentAllScreen.kt index 0bb3c0593..82e69dfd0 100644 --- a/course/src/main/java/org/openedx/course/presentation/outline/CourseOutlineScreen.kt +++ b/course/src/main/java/org/openedx/course/presentation/outline/CourseContentAllScreen.kt @@ -4,21 +4,16 @@ import android.content.res.Configuration.UI_MODE_NIGHT_NO import android.content.res.Configuration.UI_MODE_NIGHT_YES import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.defaultMinSize import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.shape.CircleShape -import androidx.compose.material.Icon -import androidx.compose.material.LinearProgressIndicator import androidx.compose.material.MaterialTheme import androidx.compose.material.Scaffold import androidx.compose.material.Surface @@ -34,20 +29,18 @@ 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 import androidx.compose.ui.res.pluralStringResource import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.tooling.preview.Devices import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import androidx.fragment.app.FragmentManager import org.openedx.core.BlockType -import org.openedx.core.NoContentScreenType import org.openedx.core.domain.model.AssignmentProgress import org.openedx.core.domain.model.Block import org.openedx.core.domain.model.BlockCounts @@ -56,10 +49,10 @@ import org.openedx.core.domain.model.CourseStructure import org.openedx.core.domain.model.CoursewareAccess import org.openedx.core.domain.model.OfflineDownload import org.openedx.core.domain.model.Progress +import org.openedx.core.extension.getChapterBlocks import org.openedx.core.presentation.course.CourseViewMode import org.openedx.core.ui.CircularProgress import org.openedx.core.ui.HandleUIMessage -import org.openedx.core.ui.NoContentScreen import org.openedx.core.ui.OpenEdXButton import org.openedx.core.ui.TextIcon import org.openedx.core.ui.displayCutoutForLandscape @@ -67,9 +60,11 @@ import org.openedx.core.ui.theme.OpenEdXTheme import org.openedx.core.ui.theme.appColors import org.openedx.core.ui.theme.appTypography import org.openedx.course.R +import org.openedx.course.presentation.contenttab.CourseContentAllEmptyState import org.openedx.course.presentation.ui.CourseDatesBanner import org.openedx.course.presentation.ui.CourseDatesBannerTablet import org.openedx.course.presentation.ui.CourseMessage +import org.openedx.course.presentation.ui.CourseProgress import org.openedx.course.presentation.ui.CourseSection import org.openedx.foundation.extension.takeIfNotEmpty import org.openedx.foundation.presentation.UIMessage @@ -79,11 +74,11 @@ import org.openedx.foundation.presentation.windowSizeValue import java.util.Date @Composable -fun CourseOutlineScreen( +fun CourseContentAllScreen( windowSize: WindowSize, - viewModel: CourseOutlineViewModel, + viewModel: CourseContentAllViewModel, fragmentManager: FragmentManager, - onResetDatesClick: () -> Unit, + onNavigateToHome: () -> Unit = {}, ) { val uiState by viewModel.uiState.collectAsState() val uiMessage by viewModel.uiMessage.collectAsState(null) @@ -96,10 +91,11 @@ fun CourseOutlineScreen( } } - CourseOutlineUI( + CourseContentAllUI( windowSize = windowSize, uiState = uiState, uiMessage = uiMessage, + onNavigateToHome = onNavigateToHome, onExpandClick = { block -> if (viewModel.switchCourseSections(block.id)) { viewModel.sequentialClickedEvent( @@ -148,11 +144,7 @@ fun CourseOutlineScreen( ) }, onResetDatesClick = { - viewModel.resetCourseDatesBanner( - onResetDates = { - onResetDatesClick() - } - ) + viewModel.resetCourseDatesBanner() }, onCertificateClick = { viewModel.viewCertificateTappedEvent() @@ -163,10 +155,11 @@ fun CourseOutlineScreen( } @Composable -private fun CourseOutlineUI( +private fun CourseContentAllUI( windowSize: WindowSize, - uiState: CourseOutlineUIState, + uiState: CourseContentAllUIState, uiMessage: UIMessage?, + onNavigateToHome: () -> Unit, onExpandClick: (Block) -> Unit, onSubSectionClick: (Block) -> Unit, onResumeClick: (String) -> Unit, @@ -224,9 +217,11 @@ private fun CourseOutlineUI( ) { Box { when (uiState) { - is CourseOutlineUIState.CourseData -> { + is CourseContentAllUIState.CourseData -> { if (uiState.courseStructure.blockData.isEmpty()) { - NoContentScreen(noContentScreenType = NoContentScreenType.COURSE_OUTLINE) + CourseContentAllEmptyState( + onReturnToCourseClick = onNavigateToHome + ) } else { LazyColumn( modifier = Modifier.fillMaxSize(), @@ -276,40 +271,39 @@ 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 + val sections = + uiState.courseStructure.blockData.getChapterBlocks() + val progress = Progress( + total = sections.size, + completed = sections.filter { it.isCompleted() }.size + ) + item { + CourseProgress( + modifier = Modifier + .fillMaxWidth() + .padding( + start = 24.dp, + end = 24.dp + ), + progress = progress, + description = pluralStringResource( + R.plurals.course_sections_complete, + progress.completed, + progress.completed, + progress.total ) - } + ) } if (uiState.resumeComponent != null) { item { Box(listPadding) { - if (windowSize.isTablet) { - ResumeCourseTablet( - modifier = Modifier.padding(vertical = 16.dp), - block = uiState.resumeComponent, - displayName = uiState.resumeUnitTitle, - onResumeClick = onResumeClick - ) - } else { - ResumeCourse( - modifier = Modifier.padding(vertical = 16.dp), - block = uiState.resumeComponent, - displayName = uiState.resumeUnitTitle, - onResumeClick = onResumeClick - ) - } + ResumeCourse( + modifier = Modifier.padding(vertical = 16.dp), + block = uiState.resumeComponent, + displayName = uiState.resumeUnitTitle, + onResumeClick = onResumeClick + ) } } } @@ -329,7 +323,7 @@ private fun CourseOutlineUI( block = section, onItemClick = onExpandClick, useRelativeDates = uiState.useRelativeDates, - courseSectionsState = courseSectionsState, + isSectionVisible = courseSectionsState, courseSubSections = courseSubSections, downloadedStateMap = uiState.downloadedState, onSubSectionClick = onSubSectionClick, @@ -341,11 +335,13 @@ private fun CourseOutlineUI( } } - CourseOutlineUIState.Error -> { - NoContentScreen(noContentScreenType = NoContentScreenType.COURSE_OUTLINE) + CourseContentAllUIState.Error -> { + CourseContentAllEmptyState( + onReturnToCourseClick = onNavigateToHome + ) } - CourseOutlineUIState.Loading -> { + CourseContentAllUIState.Loading -> { CircularProgress() } } @@ -362,139 +358,35 @@ private fun ResumeCourse( displayName: String, onResumeClick: (String) -> Unit, ) { - Column( - modifier = modifier.fillMaxWidth() - ) { - Text( - text = stringResource(id = R.string.course_continue_with), - style = MaterialTheme.appTypography.labelMedium, - color = MaterialTheme.appColors.textPrimaryVariant - ) - Spacer(Modifier.height(6.dp)) - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(4.dp) - ) { - Icon( - modifier = Modifier.size(24.dp), - painter = painterResource(id = getUnitBlockIcon(block)), - contentDescription = null, - tint = MaterialTheme.appColors.textPrimary - ) - Text( - text = displayName, - color = MaterialTheme.appColors.textPrimary, - style = MaterialTheme.appTypography.titleMedium, - maxLines = 1, - overflow = TextOverflow.Ellipsis - ) - } - Spacer(Modifier.height(24.dp)) - OpenEdXButton( - text = stringResource(id = R.string.course_resume), - onClick = { - onResumeClick(block.id) - }, - content = { - TextIcon( - text = stringResource(id = R.string.course_resume), - icon = Icons.AutoMirrored.Filled.ArrowForward, - color = MaterialTheme.appColors.primaryButtonText, - textStyle = MaterialTheme.appTypography.labelLarge - ) - } - ) - } -} - -@Composable -private fun ResumeCourseTablet( - modifier: Modifier = Modifier, - block: Block, - displayName: String, - onResumeClick: (String) -> Unit, -) { - Row( - modifier = modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween - ) { - Column( - Modifier - .weight(1f) - .padding(end = 35.dp) - ) { - Text( - text = stringResource(id = R.string.course_continue_with), - style = MaterialTheme.appTypography.labelMedium, - color = MaterialTheme.appColors.textPrimaryVariant - ) - Spacer(Modifier.height(6.dp)) + OpenEdXButton( + modifier = modifier + .fillMaxWidth() + .defaultMinSize(minHeight = 54.dp), + onClick = { + onResumeClick(block.id) + }, + content = { Row( - verticalAlignment = Alignment.Top, - horizontalArrangement = Arrangement.spacedBy(4.dp) + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween ) { - Icon( - modifier = Modifier.size(size = (MaterialTheme.appTypography.titleMedium.fontSize.value + 4).dp), - painter = painterResource(id = getUnitBlockIcon(block)), - contentDescription = null, - tint = MaterialTheme.appColors.textPrimary - ) Text( + modifier = Modifier.weight(1f), text = displayName, - color = MaterialTheme.appColors.textPrimary, + color = MaterialTheme.appColors.primaryButtonText, style = MaterialTheme.appTypography.titleMedium, - overflow = TextOverflow.Ellipsis, - maxLines = 4 + fontWeight = FontWeight.W600 ) - } - } - OpenEdXButton( - modifier = Modifier.width(210.dp), - text = stringResource(id = R.string.course_resume), - onClick = { - onResumeClick(block.id) - }, - content = { TextIcon( - text = stringResource(id = R.string.course_resume), + text = stringResource(id = R.string.course_continue), icon = Icons.AutoMirrored.Filled.ArrowForward, color = MaterialTheme.appColors.primaryButtonText, textStyle = MaterialTheme.appTypography.labelLarge ) } - ) - } -} - -@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.value, - color = MaterialTheme.appColors.progressBarColor, - backgroundColor = MaterialTheme.appColors.progressBarBackgroundColor - ) - Text( - text = pluralStringResource( - R.plurals.course_assignments_complete, - progress.assignmentsCompleted, - progress.assignmentsCompleted, - progress.totalAssignmentsCount - ), - color = MaterialTheme.appColors.textDark, - style = MaterialTheme.appTypography.labelSmall - ) - } + } + ) } fun getUnitBlockIcon(block: Block): Int { @@ -511,9 +403,9 @@ fun getUnitBlockIcon(block: Block): Int { @Composable private fun CourseOutlineScreenPreview() { OpenEdXTheme { - CourseOutlineUI( + CourseContentAllUI( windowSize = WindowSize(WindowType.Compact, WindowType.Compact), - uiState = CourseOutlineUIState.CourseData( + uiState = CourseContentAllUIState.CourseData( mockCourseStructure, mapOf(), mockChapterBlock, @@ -537,6 +429,7 @@ private fun CourseOutlineScreenPreview() { onDownloadClick = {}, onResetDatesClick = {}, onCertificateClick = {}, + onNavigateToHome = {}, ) } } @@ -544,11 +437,11 @@ private fun CourseOutlineScreenPreview() { @Preview(uiMode = UI_MODE_NIGHT_NO, device = Devices.NEXUS_9) @Preview(uiMode = UI_MODE_NIGHT_YES, device = Devices.NEXUS_9) @Composable -private fun CourseOutlineScreenTabletPreview() { +private fun CourseContentAllScreenTabletPreview() { OpenEdXTheme { - CourseOutlineUI( + CourseContentAllUI( windowSize = WindowSize(WindowType.Medium, WindowType.Medium), - uiState = CourseOutlineUIState.CourseData( + uiState = CourseContentAllUIState.CourseData( mockCourseStructure, mapOf(), mockChapterBlock, @@ -572,6 +465,7 @@ private fun CourseOutlineScreenTabletPreview() { onDownloadClick = {}, onResetDatesClick = {}, onCertificateClick = {}, + onNavigateToHome = {}, ) } } @@ -588,7 +482,8 @@ private fun ResumeCoursePreview() { private val mockAssignmentProgress = AssignmentProgress( assignmentType = "Home", numPointsEarned = 1f, - numPointsPossible = 3f + numPointsPossible = 3f, + shortLabel = "HM1" ) private val mockChapterBlock = Block( id = "id", diff --git a/course/src/main/java/org/openedx/course/presentation/outline/CourseOutlineUIState.kt b/course/src/main/java/org/openedx/course/presentation/outline/CourseContentAllUIState.kt similarity index 80% rename from course/src/main/java/org/openedx/course/presentation/outline/CourseOutlineUIState.kt rename to course/src/main/java/org/openedx/course/presentation/outline/CourseContentAllUIState.kt index 55cf52137..9a2deed32 100644 --- a/course/src/main/java/org/openedx/course/presentation/outline/CourseOutlineUIState.kt +++ b/course/src/main/java/org/openedx/course/presentation/outline/CourseContentAllUIState.kt @@ -5,7 +5,7 @@ import org.openedx.core.domain.model.CourseDatesBannerInfo import org.openedx.core.domain.model.CourseStructure import org.openedx.core.module.db.DownloadedState -sealed class CourseOutlineUIState { +sealed class CourseContentAllUIState { data class CourseData( val courseStructure: CourseStructure, val downloadedState: Map, @@ -16,8 +16,8 @@ sealed class CourseOutlineUIState { val subSectionsDownloadsCount: Map, val datesBannerInfo: CourseDatesBannerInfo, val useRelativeDates: Boolean, - ) : CourseOutlineUIState() + ) : CourseContentAllUIState() - data object Error : CourseOutlineUIState() - data object Loading : CourseOutlineUIState() + data object Error : CourseContentAllUIState() + data object Loading : CourseContentAllUIState() } diff --git a/course/src/main/java/org/openedx/course/presentation/outline/CourseOutlineViewModel.kt b/course/src/main/java/org/openedx/course/presentation/outline/CourseContentAllViewModel.kt similarity index 93% rename from course/src/main/java/org/openedx/course/presentation/outline/CourseOutlineViewModel.kt rename to course/src/main/java/org/openedx/course/presentation/outline/CourseContentAllViewModel.kt index 50fedd2dc..2c966a0cf 100644 --- a/course/src/main/java/org/openedx/course/presentation/outline/CourseOutlineViewModel.kt +++ b/course/src/main/java/org/openedx/course/presentation/outline/CourseContentAllViewModel.kt @@ -20,6 +20,7 @@ import org.openedx.core.domain.model.CourseComponentStatus import org.openedx.core.domain.model.CourseDateBlock import org.openedx.core.domain.model.CourseDatesBannerInfo import org.openedx.core.domain.model.CourseStructure +import org.openedx.core.extension.getChapterBlocks import org.openedx.core.extension.getSequentialBlocks import org.openedx.core.extension.getVerticalBlocks import org.openedx.core.module.DownloadWorkerController @@ -47,7 +48,7 @@ import org.openedx.foundation.system.ResourceManager import org.openedx.foundation.utils.FileUtil import org.openedx.course.R as courseR -class CourseOutlineViewModel( +class CourseContentAllViewModel( val courseId: String, private val courseTitle: String, private val config: Config, @@ -73,8 +74,9 @@ class CourseOutlineViewModel( ) { val isCourseDropdownNavigationEnabled get() = config.getCourseUIConfig().isCourseDropdownNavigationEnabled - private val _uiState = MutableStateFlow(CourseOutlineUIState.Loading) - val uiState: StateFlow + private val _uiState = + MutableStateFlow(CourseContentAllUIState.Loading) + val uiState: StateFlow get() = _uiState.asStateFlow() private val _uiMessage = MutableSharedFlow() @@ -115,9 +117,9 @@ class CourseOutlineViewModel( viewModelScope.launch { downloadModelsStatusFlow.collect { - if (_uiState.value is CourseOutlineUIState.CourseData) { - val state = _uiState.value as CourseOutlineUIState.CourseData - _uiState.value = CourseOutlineUIState.CourseData( + if (_uiState.value is CourseContentAllUIState.CourseData) { + val state = _uiState.value as CourseContentAllUIState.CourseData + _uiState.value = CourseContentAllUIState.CourseData( courseStructure = state.courseStructure, downloadedState = it.toMap(), resumeComponent = state.resumeComponent, @@ -158,12 +160,12 @@ class CourseOutlineViewModel( } fun switchCourseSections(blockId: String): Boolean { - return if (_uiState.value is CourseOutlineUIState.CourseData) { - val state = _uiState.value as CourseOutlineUIState.CourseData + return if (_uiState.value is CourseContentAllUIState.CourseData) { + val state = _uiState.value as CourseContentAllUIState.CourseData val courseSectionsState = state.courseSectionsState.toMutableMap() courseSectionsState[blockId] = !(state.courseSectionsState[blockId] ?: false) - _uiState.value = CourseOutlineUIState.CourseData( + _uiState.value = CourseContentAllUIState.CourseData( courseStructure = state.courseStructure, downloadedState = state.downloadedState, resumeComponent = state.resumeComponent, @@ -221,9 +223,10 @@ class CourseOutlineViewModel( initDownloadModelsStatus() val courseSectionsState = - (_uiState.value as? CourseOutlineUIState.CourseData)?.courseSectionsState.orEmpty() + (_uiState.value as? CourseContentAllUIState.CourseData)?.courseSectionsState + ?: blocks.getChapterBlocks().associate { it.id to !it.isCompleted() } - _uiState.value = CourseOutlineUIState.CourseData( + _uiState.value = CourseContentAllUIState.CourseData( courseStructure = sortedStructure, downloadedState = getDownloadModelsStatus(), resumeComponent = getResumeBlock(blocks, courseStatus.lastVisitedBlockId), @@ -237,7 +240,7 @@ class CourseOutlineViewModel( } private suspend fun handleCourseDataError(e: Throwable?) { - _uiState.value = CourseOutlineUIState.Error + _uiState.value = CourseContentAllUIState.Error val errorMessage = when { e?.isInternetError() == true -> R.string.core_error_no_connection else -> R.string.core_error_unknown_error @@ -286,13 +289,12 @@ class CourseOutlineViewModel( return resumeBlock } - fun resetCourseDatesBanner(onResetDates: (Boolean) -> Unit) { + fun resetCourseDatesBanner() { viewModelScope.launch { try { interactor.resetCourseDates(courseId = courseId) getCourseData() courseNotifier.send(CourseDatesShifted) - onResetDates(true) } catch (e: Exception) { if (e.isInternetError()) { _uiMessage.emit( @@ -307,7 +309,6 @@ class CourseOutlineViewModel( ) ) } - onResetDates(false) } } } @@ -359,7 +360,7 @@ class CourseOutlineViewModel( private fun resumeCourseTappedEvent(blockId: String) { val currentState = uiState.value - if (currentState is CourseOutlineUIState.CourseData) { + if (currentState is CourseContentAllUIState.CourseData) { analytics.logEvent( CourseAnalyticsEvent.RESUME_COURSE_CLICKED.eventName, buildMap { @@ -377,7 +378,7 @@ class CourseOutlineViewModel( fun sequentialClickedEvent(blockId: String, blockName: String) { val currentState = uiState.value - if (currentState is CourseOutlineUIState.CourseData) { + if (currentState is CourseContentAllUIState.CourseData) { analytics.sequentialClickedEvent( courseId, currentState.courseStructure.name, @@ -389,7 +390,7 @@ class CourseOutlineViewModel( fun logUnitDetailViewedEvent(blockId: String, blockName: String) { val currentState = uiState.value - if (currentState is CourseOutlineUIState.CourseData) { + if (currentState is CourseContentAllUIState.CourseData) { analytics.logEvent( CourseAnalyticsEvent.UNIT_DETAIL.eventName, buildMap { @@ -417,7 +418,7 @@ class CourseOutlineViewModel( fun downloadBlocks(blocksIds: List, fragmentManager: FragmentManager) { viewModelScope.launch { - val courseData = _uiState.value as? CourseOutlineUIState.CourseData ?: return@launch + val courseData = _uiState.value as? CourseContentAllUIState.CourseData ?: return@launch val subSectionsBlocks = courseData.courseSubSections.values.flatten().filter { it.id in blocksIds } diff --git a/course/src/main/java/org/openedx/course/presentation/progress/CourseProgressScreen.kt b/course/src/main/java/org/openedx/course/presentation/progress/CourseProgressScreen.kt index 57b13d80b..47a01e416 100644 --- a/course/src/main/java/org/openedx/course/presentation/progress/CourseProgressScreen.kt +++ b/course/src/main/java/org/openedx/course/presentation/progress/CourseProgressScreen.kt @@ -449,7 +449,7 @@ private fun CourseCompletionView( ) .padding(3.dp), progress = progress.completion, - color = MaterialTheme.appColors.progressBarColor, + color = MaterialTheme.appColors.primary, backgroundColor = MaterialTheme.appColors.progressBarBackgroundColor, strokeWidth = 10.dp, strokeCap = StrokeCap.Round @@ -513,7 +513,7 @@ private fun AssignmentTypeRow( ) { Text( text = stringResource( - R.string.progress_earned_possible_assignment_problems, + R.string.course_progress_earned_possible_assignment_problems, earned.toInt(), possible.toInt() ), @@ -526,7 +526,7 @@ private fun AssignmentTypeRow( append("${(policy.weight * 100).toInt()}%") } append(" ") - append(stringResource(R.string.progress_of_grade)) + append(stringResource(R.string.course_progress_of_grade)) }, style = MaterialTheme.appTypography.bodySmall, color = MaterialTheme.appColors.textDark, @@ -534,7 +534,7 @@ private fun AssignmentTypeRow( } Text( stringResource( - R.string.progress_current_and_max_weighted_graded_percent, + R.string.course_progress_current_and_max_weighted_graded_percent, progress.getAssignmentWeightedGradedPercent(policy).toInt(), (policy.weight * 100).toInt() ), 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 d1f784227..0fb24ebd6 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 @@ -405,7 +405,7 @@ private val mockBlock = Block( descendantsType = BlockType.HTML, completion = 0.0, containsGatedContent = false, - assignmentProgress = AssignmentProgress("", 1f, 2f), + assignmentProgress = AssignmentProgress("", 1f, 2f, "HM1"), due = Date(), offlineDownload = null ) 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 755ecbafa..54075d183 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 @@ -14,12 +14,15 @@ 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.navigationBarsPadding +import androidx.compose.foundation.layout.offset import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.statusBarsPadding @@ -27,7 +30,10 @@ import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.wrapContentHeight import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyListState +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.shape.CircleShape import androidx.compose.material.Button import androidx.compose.material.ButtonDefaults @@ -62,8 +68,10 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.draw.alpha import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.rotate +import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.painter.Painter +import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalConfiguration import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.painterResource @@ -78,12 +86,15 @@ 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.CourseDatesBannerInfo +import org.openedx.core.domain.model.Progress import org.openedx.core.extension.safeDivBy import org.openedx.core.module.db.DownloadModel import org.openedx.core.module.db.DownloadedState @@ -99,6 +110,7 @@ import org.openedx.core.ui.theme.appColors import org.openedx.core.ui.theme.appShapes import org.openedx.core.ui.theme.appTypography import org.openedx.core.utils.TimeUtils +import org.openedx.core.utils.VideoPreview import org.openedx.course.R import org.openedx.course.presentation.dates.mockedCourseBannerInfo import org.openedx.course.presentation.outline.getUnitBlockIcon @@ -155,15 +167,6 @@ fun CourseSectionCard( 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), @@ -177,11 +180,12 @@ fun CourseSectionCard( } else { Icons.Outlined.CloudDownload } - 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 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) } @@ -211,7 +215,7 @@ fun CourseSectionCard( Icon( imageVector = Icons.Filled.Close, contentDescription = - stringResource(id = R.string.course_accessibility_stop_downloading_course_section), + stringResource(id = R.string.course_accessibility_stop_downloading_course_section), tint = MaterialTheme.appColors.error ) } @@ -300,11 +304,12 @@ fun OfflineQueueCard( @Composable fun CardArrow( degrees: Float, + tint: Color = MaterialTheme.appColors.textDark, ) { Icon( imageVector = Icons.AutoMirrored.Filled.KeyboardArrowRight, - tint = MaterialTheme.appColors.textDark, - contentDescription = "Expandable Arrow", + tint = tint, + contentDescription = null, modifier = Modifier.rotate(degrees), ) } @@ -604,20 +609,308 @@ fun VideoSubtitles( } } +@Composable +fun CourseVideoSection( + block: Block, + videoBlocks: List, + preview: Map, + progress: Map, + downloadedStateMap: Map, + onVideoClick: (Block) -> Unit, + onDownloadClick: (blocksIds: List) -> Unit, +) { + val state = rememberLazyListState() + val subSectionIds = videoBlocks.map { it.id } + val filteredStatuses = downloadedStateMap.filterKeys { it in subSectionIds }.values + val downloadedState = when { + filteredStatuses.isEmpty() -> null + filteredStatuses.all { it.isDownloaded } -> DownloadedState.DOWNLOADED + filteredStatuses.any { it.isWaitingOrDownloading } -> DownloadedState.DOWNLOADING + else -> DownloadedState.NOT_DOWNLOADED + } + + LaunchedEffect(Unit) { + try { + val uncompletedBlockIndex = videoBlocks.indexOf(videoBlocks.find { !it.isCompleted() }) + state.scrollToItem(uncompletedBlockIndex) + } catch (e: Exception) { + e.printStackTrace() + } + } + + Column( + modifier = Modifier.padding(vertical = 8.dp) + ) { + CourseVideoSectionHeader( + block = block, + downloadedState = downloadedState, + videoBlocks = videoBlocks, + onDownloadClick = { + onDownloadClick(block.descendants) + } + ) + LazyRow( + state = state, + horizontalArrangement = Arrangement.spacedBy(8.dp), + contentPadding = PaddingValues( + top = 8.dp, + bottom = 16.dp, + start = 16.dp, + end = 16.dp, + ) + ) { + items(videoBlocks) { block -> + CourseVideoItem( + videoBlock = block, + preview = preview[block.id], + progress = progress[block.id] ?: 0f, + onClick = { + onVideoClick(block) + } + ) + } + } + Divider(modifier = Modifier.fillMaxWidth()) + } +} + +@Composable +fun CourseVideoItem( + videoBlock: Block, + preview: VideoPreview?, + progress: Float, + onClick: () -> Unit +) { + Box( + modifier = Modifier + .width(192.dp) + .height(108.dp) + .clip(MaterialTheme.appShapes.videoPreviewShape) + .let { + if (videoBlock.isCompleted()) { + it.border( + width = 3.dp, + color = MaterialTheme.appColors.successGreen, + shape = MaterialTheme.appShapes.videoPreviewShape + ) + } else { + it + } + } + .clickable { onClick() } + ) { + AsyncImage( + modifier = Modifier + .fillMaxSize(), + model = ImageRequest.Builder(LocalContext.current) + .data(preview?.link ?: preview?.bitmap) + .error(coreR.drawable.core_no_image_course) + .placeholder(coreR.drawable.core_no_image_course) + .build(), + contentDescription = stringResource(R.string.course_accessibility_video_player), + contentScale = ContentScale.Crop + ) + + Box( + modifier = Modifier + .fillMaxSize() + .background( + brush = Brush.verticalGradient( + colors = listOf( + Color.Black.copy(alpha = 0.6f), + Color.Transparent, + ), + startY = 0f, + endY = Float.POSITIVE_INFINITY + ) + ) + ) + + Image( + modifier = Modifier + .size(32.dp) + .align(Alignment.Center), + painter = painterResource(id = R.drawable.course_video_play_button), + contentDescription = null, + ) + + // Title (top-left) + Text( + text = videoBlock.displayName, + color = Color.White, + style = MaterialTheme.appTypography.bodySmall, + modifier = Modifier + .align(Alignment.TopStart) + .padding(8.dp), + maxLines = 2, + overflow = TextOverflow.Ellipsis + ) + + // Progress bar (bottom) + if (progress > 0.0f) { + Box( + modifier = Modifier + .padding(bottom = 4.dp) + .height(16.dp) + .align(Alignment.BottomCenter), + contentAlignment = Alignment.Center + ) { + LinearProgressIndicator( + modifier = Modifier + .fillMaxWidth() + .height(4.dp) + .padding(horizontal = 8.dp) + .clip(CircleShape), + progress = progress, + color = if (videoBlock.isCompleted() && progress > 0.95f) { + MaterialTheme.appColors.progressBarColor + } else { + MaterialTheme.appColors.primary + }, + backgroundColor = MaterialTheme.appColors.progressBarBackgroundColor + ) + if (videoBlock.isCompleted()) { + Image( + modifier = Modifier + .align(Alignment.BottomEnd) + .size(16.dp) + .offset(x = (-4).dp), + painter = painterResource(id = coreR.drawable.ic_core_check), + contentDescription = stringResource(R.string.course_accessibility_video_watched), + ) + } + } + } + } +} + +@Composable +fun CourseVideoSectionHeader( + modifier: Modifier = Modifier, + block: Block, + videoBlocks: List?, + downloadedState: DownloadedState?, + onDownloadClick: () -> Unit, +) { + Row( + modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(4.dp) + ) { + Column( + modifier = Modifier.weight(1f), + ) { + Text( + text = block.displayName, + style = MaterialTheme.appTypography.titleSmall, + color = MaterialTheme.appColors.textPrimary, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + Text( + text = stringResource( + R.string.course_video_watched, + videoBlocks?.filter { it.isCompleted() }?.size ?: 0, + videoBlocks?.size ?: 0 + ), + style = MaterialTheme.appTypography.bodySmall, + color = MaterialTheme.appColors.textPrimary, + ) + } + DownloadIcon( + downloadedState = downloadedState, + onDownloadClick = onDownloadClick + ) + } +} + +@Composable +fun DownloadIcon( + downloadedState: DownloadedState?, + onDownloadClick: () -> Unit, +) { + val iconModifier = Modifier.size(24.dp) + Box( + modifier = Modifier.fillMaxHeight(), + contentAlignment = Alignment.Center + ) { + if (downloadedState == DownloadedState.DOWNLOADED || downloadedState == DownloadedState.NOT_DOWNLOADED) { + val downloadIcon = if (downloadedState == DownloadedState.DOWNLOADED) { + Icons.Default.CloudDone + } else { + Icons.Outlined.CloudDownload + } + 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.textAccent + } + IconButton( + modifier = iconModifier, + onClick = { onDownloadClick() } + ) { + Icon( + imageVector = downloadIcon, + contentDescription = downloadIconDescription, + tint = downloadIconTint + ) + } + } else if (downloadedState != null) { + Box(contentAlignment = Alignment.Center) { + 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 = coreR.drawable.core_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() } + ) { + Icon( + imageVector = Icons.Filled.Close, + contentDescription = stringResource( + id = R.string.course_accessibility_stop_downloading_course_section + ), + tint = MaterialTheme.appColors.error + ) + } + } + } + } +} + @Composable fun CourseSection( modifier: Modifier = Modifier, block: Block, useRelativeDates: Boolean, onItemClick: (Block) -> Unit, - courseSectionsState: Boolean?, + isSectionVisible: Boolean?, courseSubSections: List?, downloadedStateMap: Map, onSubSectionClick: (Block) -> Unit, onDownloadClick: (blocksIds: List) -> Unit, ) { val arrowRotation by animateFloatAsState( - targetValue = if (courseSectionsState == true) { + targetValue = if (isSectionVisible == true) { -90f } else { 90f @@ -633,17 +926,30 @@ fun CourseSection( else -> DownloadedState.NOT_DOWNLOADED } + // Section progress + val completedCount = courseSubSections?.count { it.isCompleted() } ?: 0 + val totalCount = courseSubSections?.size ?: 0 + val progress = if (totalCount > 0) completedCount.toFloat() / totalCount else 0f + Column( modifier = modifier - .clip(MaterialTheme.appShapes.cardShape) + .clip(MaterialTheme.appShapes.sectionCardShape) .noRippleClickable { onItemClick(block) } .background(MaterialTheme.appColors.cardViewBackground) .border( 1.dp, MaterialTheme.appColors.cardViewBorder, - MaterialTheme.appShapes.cardShape + MaterialTheme.appShapes.sectionCardShape ) ) { + LinearProgressIndicator( + modifier = Modifier + .fillMaxWidth() + .height(6.dp), + progress = progress, + color = MaterialTheme.appColors.progressBarColor, + backgroundColor = MaterialTheme.appColors.progressBarBackgroundColor + ) CourseExpandableChapterCard( block = block, arrowDegrees = arrowRotation, @@ -654,7 +960,7 @@ fun CourseSection( ) courseSubSections?.forEach { subSectionBlock -> AnimatedVisibility( - visible = courseSectionsState == true + visible = isSectionVisible == true ) { CourseSubSectionItem( block = subSectionBlock, @@ -674,7 +980,6 @@ fun CourseExpandableChapterCard( downloadedState: DownloadedState?, onDownloadClick: () -> Unit, ) { - val iconModifier = Modifier.size(24.dp) Row( modifier .fillMaxWidth() @@ -688,7 +993,8 @@ fun CourseExpandableChapterCard( 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) + val completedIconDescription = + stringResource(id = R.string.course_accessibility_section_completed) Icon( painter = completedIconPainter, @@ -704,69 +1010,10 @@ fun CourseExpandableChapterCard( maxLines = 1, overflow = TextOverflow.Ellipsis ) - Row( - modifier = Modifier.fillMaxHeight(), - verticalAlignment = Alignment.CenterVertically - ) { - if (downloadedState == DownloadedState.DOWNLOADED || downloadedState == DownloadedState.NOT_DOWNLOADED) { - val downloadIcon = if (downloadedState == DownloadedState.DOWNLOADED) { - Icons.Default.CloudDone - } else { - Icons.Outlined.CloudDownload - } - 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.textAccent - } - IconButton( - modifier = iconModifier, - onClick = { onDownloadClick() } - ) { - Icon( - imageVector = downloadIcon, - contentDescription = downloadIconDescription, - tint = downloadIconTint - ) - } - } else if (downloadedState != null) { - Box(contentAlignment = Alignment.Center) { - 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 = coreR.drawable.core_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() } - ) { - Icon( - imageVector = Icons.Filled.Close, - contentDescription = stringResource( - id = R.string.course_accessibility_stop_downloading_course_section - ), - tint = MaterialTheme.appColors.error - ) - } - } - } - } + DownloadIcon( + downloadedState = downloadedState, + onDownloadClick = onDownloadClick + ) } } @@ -789,9 +1036,10 @@ fun CourseSubSectionItem( MaterialTheme.appColors.onSurface } val due by rememberSaveable { - mutableStateOf(block.due?.let { TimeUtils.formatToString(context, it, useRelativeDates) } ?: "") + mutableStateOf( + block.due?.let { TimeUtils.formatToString(context, it, useRelativeDates) } + ) } - val isAssignmentEnable = !block.isCompleted() && block.assignmentProgress != null && due.isNotEmpty() Column( modifier = modifier .fillMaxWidth() @@ -817,7 +1065,7 @@ fun CourseSubSectionItem( maxLines = 1 ) Spacer(modifier = Modifier.width(16.dp)) - if (isAssignmentEnable) { + if (due != null) { Icon( imageVector = Icons.AutoMirrored.Filled.KeyboardArrowRight, tint = MaterialTheme.appColors.onSurface, @@ -825,16 +1073,27 @@ fun CourseSubSectionItem( ) } } - - if (isAssignmentEnable) { - val assignmentString = + val strings = listOf( + block.assignmentProgress?.assignmentType, + due?.let { stringResource( - coreR.string.core_subsection_assignment_info, - block.assignmentProgress?.assignmentType ?: "", - stringResource(id = coreR.string.core_date_format_assignment_due, due), - block.assignmentProgress?.numPointsEarned?.toInt() ?: 0, - block.assignmentProgress?.numPointsPossible?.toInt() ?: 0 + id = coreR.string.core_date_format_assignment_due, + it ) + }, + block.assignmentProgress?.numPointsPossible?.let { + if (it > 0) { + block.assignmentProgress?.toPointString(" ") + } else { + null + } + } + ) + val assignmentString = strings + .filter { !it.isNullOrEmpty() } + .joinToString(" - ") + + if (assignmentString.isNotEmpty()) { Spacer(modifier = Modifier.height(8.dp)) Text( text = assignmentString, @@ -1211,6 +1470,74 @@ fun CourseMessage( } } +@Composable +fun CourseProgress( + modifier: Modifier = Modifier, + progress: Progress, + description: String, + isCompletedShown: Boolean = false, + onVisibilityChanged: (() -> Unit)? = null +) { + val arrowRotation by animateFloatAsState( + targetValue = if (isCompletedShown) { + -90f + } else { + 90f + }, + label = "" + ) + val buttonText = if (isCompletedShown) { + stringResource(R.string.course_hide_completed) + } else { + stringResource(R.string.course_view_completed) + } + Column( + modifier = modifier, + ) { + LinearProgressIndicator( + modifier = Modifier + .fillMaxWidth() + .height(4.dp) + .clip(CircleShape), + progress = progress.value, + color = MaterialTheme.appColors.progressBarColor, + backgroundColor = MaterialTheme.appColors.progressBarBackgroundColor + ) + Row( + modifier = Modifier + .fillMaxWidth() + .height(24.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween + ) { + Text( + text = description, + color = MaterialTheme.appColors.textDark, + style = MaterialTheme.appTypography.labelSmall + ) + if (onVisibilityChanged != null) { + Row( + modifier = Modifier.clickable { + onVisibilityChanged() + }, + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(4.dp) + ) { + Text( + text = buttonText, + color = MaterialTheme.appColors.textAccent, + style = MaterialTheme.appTypography.labelMedium + ) + CardArrow( + degrees = arrowRotation, + tint = MaterialTheme.appColors.textAccent, + ) + } + } + } + } +} + @Preview(uiMode = Configuration.UI_MODE_NIGHT_NO) @Preview(uiMode = Configuration.UI_MODE_NIGHT_YES) @Composable @@ -1375,7 +1702,7 @@ private val mockChapterBlock = Block( descendantsType = BlockType.CHAPTER, completion = 0.0, containsGatedContent = false, - assignmentProgress = AssignmentProgress("", 1f, 2f), + assignmentProgress = AssignmentProgress("", 1f, 2f, "HM1"), due = Date(), offlineDownload = null ) 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 deleted file mode 100644 index b020a11cc..000000000 --- a/course/src/main/java/org/openedx/course/presentation/ui/CourseVideosUI.kt +++ /dev/null @@ -1,776 +0,0 @@ -package org.openedx.course.presentation.ui - -import android.content.res.Configuration -import androidx.compose.animation.core.LinearEasing -import androidx.compose.animation.core.animateFloatAsState -import androidx.compose.animation.core.tween -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.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.widthIn -import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.material.AlertDialog -import androidx.compose.material.CircularProgressIndicator -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 -import androidx.compose.material.Switch -import androidx.compose.material.SwitchDefaults -import androidx.compose.material.Text -import androidx.compose.material.TextButton -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight -import androidx.compose.material.icons.outlined.Settings -import androidx.compose.material.icons.outlined.Videocam -import androidx.compose.material.rememberScaffoldState -import androidx.compose.runtime.Composable -import androidx.compose.runtime.collectAsState -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.saveable.rememberSaveable -import androidx.compose.runtime.setValue -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.tooling.preview.Devices -import androidx.compose.ui.tooling.preview.Preview -import androidx.compose.ui.unit.Dp -import androidx.compose.ui.unit.dp -import androidx.fragment.app.FragmentManager -import org.koin.compose.koinInject -import org.openedx.core.AppDataConstants -import org.openedx.core.BlockType -import org.openedx.core.NoContentScreenType -import org.openedx.core.domain.model.AssignmentProgress -import org.openedx.core.domain.model.Block -import org.openedx.core.domain.model.BlockCounts -import org.openedx.core.domain.model.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.module.download.DownloadModelsSize -import org.openedx.core.presentation.course.CourseViewMode -import org.openedx.core.presentation.settings.video.VideoQualityType -import org.openedx.core.ui.CircularProgress -import org.openedx.core.ui.HandleUIMessage -import org.openedx.core.ui.NoContentScreen -import org.openedx.core.ui.displayCutoutForLandscape -import org.openedx.core.ui.theme.OpenEdXTheme -import org.openedx.core.ui.theme.appColors -import org.openedx.core.ui.theme.appTypography -import org.openedx.course.presentation.videos.CourseVideoViewModel -import org.openedx.course.presentation.videos.CourseVideosUIState -import org.openedx.foundation.extension.toFileSize -import org.openedx.foundation.presentation.UIMessage -import org.openedx.foundation.presentation.WindowSize -import org.openedx.foundation.presentation.WindowType -import org.openedx.foundation.presentation.windowSizeValue -import org.openedx.foundation.utils.FileUtil -import java.util.Date -import org.openedx.core.R as coreR - -@Composable -fun CourseVideosScreen( - windowSize: WindowSize, - viewModel: CourseVideoViewModel, - fragmentManager: FragmentManager -) { - val uiState by viewModel.uiState.collectAsState(CourseVideosUIState.Loading) - val uiMessage by viewModel.uiMessage.collectAsState(null) - val videoSettings by viewModel.videoSettings.collectAsState() - val fileUtil: FileUtil = koinInject() - - CourseVideosUI( - windowSize = windowSize, - uiState = uiState, - uiMessage = uiMessage, - courseTitle = viewModel.courseTitle, - videoSettings = videoSettings, - onExpandClick = { block -> - viewModel.switchCourseSections(block.id) - }, - onSubSectionClick = { subSectionBlock -> - if (viewModel.isCourseDropdownNavigationEnabled) { - viewModel.courseSubSectionUnit[subSectionBlock.id]?.let { unit -> - viewModel.courseRouter.navigateToCourseContainer( - fragmentManager, - courseId = viewModel.courseId, - unitId = unit.id, - mode = CourseViewMode.VIDEOS - ) - } - } else { - viewModel.sequentialClickedEvent( - subSectionBlock.blockId, - subSectionBlock.displayName - ) - viewModel.courseRouter.navigateToCourseSubsections( - fm = fragmentManager, - courseId = viewModel.courseId, - subSectionId = subSectionBlock.id, - mode = CourseViewMode.VIDEOS - ) - } - }, - onDownloadClick = { blocksIds -> - viewModel.downloadBlocks( - blocksIds = blocksIds, - fragmentManager = fragmentManager, - ) - }, - onDownloadAllClick = { isAllBlocksDownloadedOrDownloading -> - viewModel.logBulkDownloadToggleEvent( - !isAllBlocksDownloadedOrDownloading, - viewModel.courseId - ) - if (isAllBlocksDownloadedOrDownloading) { - viewModel.removeAllDownloadModels() - } else { - viewModel.saveAllDownloadModels( - fileUtil.getExternalAppDir().path, - viewModel.courseId - ) - } - }, - onDownloadQueueClick = { - if (viewModel.hasDownloadModelsInQueue()) { - viewModel.courseRouter.navigateToDownloadQueue(fm = fragmentManager) - } - }, - onVideoDownloadQualityClick = { - if (viewModel.hasDownloadModelsInQueue()) { - viewModel.onChangingVideoQualityWhileDownloading() - } else { - viewModel.courseRouter.navigateToVideoQuality( - fragmentManager, - VideoQualityType.Download - ) - } - } - ) -} - -@Composable -private fun CourseVideosUI( - windowSize: WindowSize, - uiState: CourseVideosUIState, - uiMessage: UIMessage?, - courseTitle: String, - videoSettings: VideoSettings, - onExpandClick: (Block) -> Unit, - onSubSectionClick: (Block) -> Unit, - onDownloadClick: (blocksIds: List) -> Unit, - onDownloadAllClick: (Boolean) -> Unit, - onDownloadQueueClick: () -> Unit, - onVideoDownloadQualityClick: () -> Unit -) { - val scaffoldState = rememberScaffoldState() - - Scaffold( - modifier = Modifier - .fillMaxSize(), - scaffoldState = scaffoldState, - backgroundColor = MaterialTheme.appColors.background - ) { - val screenWidth by remember(key1 = windowSize) { - mutableStateOf( - windowSize.windowSizeValue( - expanded = Modifier.widthIn(Dp.Unspecified, 560.dp), - compact = Modifier.fillMaxWidth() - ) - ) - } - - val listBottomPadding by remember(key1 = windowSize) { - mutableStateOf( - windowSize.windowSizeValue( - expanded = PaddingValues(bottom = 24.dp), - compact = PaddingValues(bottom = 24.dp) - ) - ) - } - - val listPadding by remember(key1 = windowSize) { - mutableStateOf( - windowSize.windowSizeValue( - expanded = Modifier.padding(horizontal = 6.dp), - compact = Modifier.padding(horizontal = 24.dp) - ) - ) - } - - var isDownloadConfirmationShowed by rememberSaveable { - mutableStateOf(false) - } - - var isDeleteDownloadsConfirmationShowed by rememberSaveable { - mutableStateOf(false) - } - - var deleteDownloadBlock by rememberSaveable { - mutableStateOf(null) - } - - HandleUIMessage(uiMessage = uiMessage, scaffoldState = scaffoldState) - - Box( - modifier = Modifier - .fillMaxSize() - .padding(it) - .displayCutoutForLandscape(), - contentAlignment = Alignment.TopCenter - ) { - Surface( - modifier = screenWidth, - color = MaterialTheme.appColors.background - ) { - Box { - Column( - Modifier - .fillMaxSize() - ) { - when (uiState) { - is CourseVideosUIState.Empty -> { - NoContentScreen(noContentScreenType = NoContentScreenType.COURSE_VIDEOS) - } - - is CourseVideosUIState.CourseData -> { - LazyColumn( - modifier = Modifier.fillMaxSize(), - contentPadding = listBottomPadding - ) { - if (uiState.downloadModelsSize.allCount > 0) { - item { - AllVideosDownloadItem( - downloadModelsSize = uiState.downloadModelsSize, - videoSettings = videoSettings, - onShowDownloadConfirmationDialog = { - isDownloadConfirmationShowed = true - }, - onDownloadAllClick = { isSwitched -> - if (isSwitched) { - isDeleteDownloadsConfirmationShowed = true - } else { - onDownloadAllClick(false) - } - }, - onDownloadQueueClick = onDownloadQueueClick, - onVideoDownloadQualityClick = onVideoDownloadQualityClick - ) - } - } - - item { - Spacer(modifier = Modifier.height(12.dp)) - } - uiState.courseStructure.blockData.forEach { section -> - val courseSubSections = - uiState.courseSubSections[section.id] - val courseSectionsState = - uiState.courseSectionsState[section.id] - - item { - CourseSection( - modifier = listPadding.padding(vertical = 4.dp), - block = section, - onItemClick = onExpandClick, - courseSectionsState = courseSectionsState, - courseSubSections = courseSubSections, - downloadedStateMap = uiState.downloadedState, - useRelativeDates = uiState.useRelativeDates, - onSubSectionClick = onSubSectionClick, - onDownloadClick = onDownloadClick - ) - } - } - } - } - - CourseVideosUIState.Loading -> { - CircularProgress() - } - } - } - } - } - } - - if (isDownloadConfirmationShowed) { - AlertDialog( - title = { - Text( - text = stringResource(id = coreR.string.core_download_big_files_confirmation_title) - ) - }, - text = { - Text( - text = stringResource(id = coreR.string.core_download_big_files_confirmation_text) - ) - }, - onDismissRequest = { - isDownloadConfirmationShowed = false - }, - confirmButton = { - TextButton( - onClick = { - isDownloadConfirmationShowed = false - onDownloadAllClick(false) - } - ) { - Text( - text = stringResource(id = coreR.string.core_confirm) - ) - } - }, - dismissButton = { - TextButton( - onClick = { - isDownloadConfirmationShowed = false - } - ) { - Text(text = stringResource(id = coreR.string.core_dismiss)) - } - } - ) - } - - if (isDeleteDownloadsConfirmationShowed) { - val downloadModelsSize = - (uiState as? CourseVideosUIState.CourseData)?.downloadModelsSize - val isDownloadedAllVideos = - downloadModelsSize?.isAllBlocksDownloadedOrDownloading == true && - downloadModelsSize.remainingCount == 0 - val dialogTextId = if (isDownloadedAllVideos) { - coreR.string.core_delete_confirmation - } else { - coreR.string.core_delete_in_process_confirmation - } - - AlertDialog( - title = { - Text( - text = stringResource(id = coreR.string.core_warning) - ) - }, - text = { - Text( - text = stringResource(id = dialogTextId, courseTitle) - ) - }, - onDismissRequest = { - isDeleteDownloadsConfirmationShowed = false - }, - confirmButton = { - TextButton( - onClick = { - isDeleteDownloadsConfirmationShowed = false - onDownloadAllClick(true) - } - ) { - Text( - text = stringResource(id = coreR.string.core_delete) - ) - } - }, - dismissButton = { - TextButton( - onClick = { - isDeleteDownloadsConfirmationShowed = false - } - ) { - Text(text = stringResource(id = coreR.string.core_cancel)) - } - } - ) - } - - if (deleteDownloadBlock != null) { - AlertDialog( - title = { - Text( - text = stringResource(id = coreR.string.core_warning) - ) - }, - text = { - Text( - text = stringResource( - id = coreR.string.core_delete_download_confirmation_text, - deleteDownloadBlock?.displayName ?: "" - ) - ) - }, - onDismissRequest = { - deleteDownloadBlock = null - }, - confirmButton = { - TextButton( - onClick = { - deleteDownloadBlock?.let { block -> - onDownloadClick(listOf(block.id)) - } - deleteDownloadBlock = null - } - ) { - Text( - text = stringResource(id = coreR.string.core_delete) - ) - } - }, - dismissButton = { - TextButton( - onClick = { - deleteDownloadBlock = null - } - ) { - Text(text = stringResource(id = coreR.string.core_cancel)) - } - } - ) - } - } -} - -@Composable -private fun AllVideosDownloadItem( - downloadModelsSize: DownloadModelsSize, - videoSettings: VideoSettings, - onShowDownloadConfirmationDialog: () -> Unit, - onDownloadAllClick: (Boolean) -> Unit, - onDownloadQueueClick: () -> Unit, - onVideoDownloadQualityClick: () -> Unit -) { - val isDownloadingAllVideos = - downloadModelsSize.isAllBlocksDownloadedOrDownloading && - downloadModelsSize.remainingCount > 0 - val isDownloadedAllVideos = - downloadModelsSize.isAllBlocksDownloadedOrDownloading && - downloadModelsSize.remainingCount == 0 - - val downloadVideoTitleRes = when { - isDownloadingAllVideos -> coreR.string.core_video_downloading_to_device - isDownloadedAllVideos -> coreR.string.core_video_downloaded_to_device - else -> coreR.string.core_video_download_to_device - } - val downloadVideoSubTitle = - if (isDownloadedAllVideos) { - stringResource( - id = coreR.string.core_video_downloaded_subtitle, - downloadModelsSize.allCount, - downloadModelsSize.allSize.toFileSize() - ) - } else { - stringResource( - id = coreR.string.core_video_remaining_to_download, - downloadModelsSize.remainingCount, - downloadModelsSize.remainingSize.toFileSize() - ) - } - - Row( - modifier = Modifier - .fillMaxWidth() - .clickable { - onDownloadQueueClick() - }, - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically - ) { - if (isDownloadingAllVideos) { - CircularProgressIndicator( - modifier = Modifier - .padding(start = 16.dp) - .size(24.dp), - color = MaterialTheme.appColors.primary, - strokeWidth = 2.dp - ) - } else { - Icon( - modifier = Modifier - .padding(start = 16.dp), - imageVector = Icons.Outlined.Videocam, - tint = MaterialTheme.appColors.onSurface, - contentDescription = null - ) - } - Column( - modifier = Modifier - .weight(1f) - .padding(8.dp) - ) { - Text( - text = stringResource(id = downloadVideoTitleRes), - color = MaterialTheme.appColors.textPrimary, - style = MaterialTheme.appTypography.titleMedium - ) - Spacer(modifier = Modifier.height(2.dp)) - Text( - text = downloadVideoSubTitle, - color = MaterialTheme.appColors.textSecondary, - style = MaterialTheme.appTypography.labelMedium - ) - } - val isChecked = downloadModelsSize.isAllBlocksDownloadedOrDownloading - Switch( - modifier = Modifier - .padding(end = 16.dp), - checked = isChecked, - onCheckedChange = { - if (!isChecked) { - if ( - downloadModelsSize.remainingSize > AppDataConstants.DOWNLOADS_CONFIRMATION_SIZE - ) { - onShowDownloadConfirmationDialog() - } else { - onDownloadAllClick(false) - } - } else { - onDownloadAllClick(true) - } - }, - colors = SwitchDefaults.colors( - uncheckedThumbColor = MaterialTheme.appColors.primary, - checkedThumbColor = MaterialTheme.appColors.primary, - checkedTrackColor = MaterialTheme.appColors.primary - ) - ) - } - if (isDownloadingAllVideos) { - val progress = - if (downloadModelsSize.allSize == 0L) { - 0f - } else { - 1 - downloadModelsSize.remainingSize.toFloat() / downloadModelsSize.allSize - } - - val animatedProgress by animateFloatAsState( - targetValue = progress, - animationSpec = tween(durationMillis = 2000, easing = LinearEasing), - label = "ProgressAnimation" - ) - LinearProgressIndicator( - modifier = Modifier - .fillMaxWidth(), - progress = animatedProgress - ) - } - Divider() - Row( - modifier = Modifier - .fillMaxWidth() - .clickable { - onVideoDownloadQualityClick() - }, - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically - ) { - Icon( - modifier = Modifier - .padding(start = 16.dp), - imageVector = Icons.Outlined.Settings, - tint = MaterialTheme.appColors.onSurface, - contentDescription = null - ) - Column( - modifier = Modifier - .weight(1f) - .padding(8.dp) - ) { - Text( - text = stringResource(id = coreR.string.core_video_download_quality), - color = MaterialTheme.appColors.textPrimary, - style = MaterialTheme.appTypography.titleMedium - ) - Spacer(modifier = Modifier.height(2.dp)) - Text( - text = stringResource(id = videoSettings.videoDownloadQuality.titleResId), - color = MaterialTheme.appColors.textSecondary, - style = MaterialTheme.appTypography.labelMedium - ) - } - Icon( - modifier = Modifier - .padding(end = 16.dp), - imageVector = Icons.AutoMirrored.Filled.KeyboardArrowRight, - tint = MaterialTheme.appColors.onSurface, - contentDescription = "Expandable Arrow" - ) - } - Divider() -} - -@Preview(uiMode = Configuration.UI_MODE_NIGHT_NO) -@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES) -@Composable -private fun CourseVideosScreenPreview() { - OpenEdXTheme { - CourseVideosUI( - windowSize = WindowSize(WindowType.Compact, WindowType.Compact), - uiMessage = null, - uiState = CourseVideosUIState.CourseData( - mockCourseStructure, - emptyMap(), - mapOf(), - mapOf(), - mapOf(), - DownloadModelsSize( - isAllBlocksDownloadedOrDownloading = false, - remainingCount = 0, - remainingSize = 0, - allCount = 1, - allSize = 0 - ), - useRelativeDates = true - ), - courseTitle = "", - onExpandClick = { }, - onSubSectionClick = { }, - videoSettings = VideoSettings.default, - onDownloadClick = {}, - onDownloadAllClick = {}, - onDownloadQueueClick = {}, - onVideoDownloadQualityClick = {} - ) - } -} - -@Preview(uiMode = Configuration.UI_MODE_NIGHT_NO) -@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES) -@Composable -private fun CourseVideosScreenEmptyPreview() { - OpenEdXTheme { - CourseVideosUI( - windowSize = WindowSize(WindowType.Compact, WindowType.Compact), - uiMessage = null, - uiState = CourseVideosUIState.Empty, - courseTitle = "", - onExpandClick = { }, - onSubSectionClick = { }, - videoSettings = VideoSettings.default, - onDownloadClick = {}, - onDownloadAllClick = {}, - onDownloadQueueClick = {}, - onVideoDownloadQualityClick = {} - ) - } -} - -@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 CourseVideosScreenTabletPreview() { - OpenEdXTheme { - CourseVideosUI( - windowSize = WindowSize(WindowType.Medium, WindowType.Medium), - uiMessage = null, - uiState = CourseVideosUIState.CourseData( - mockCourseStructure, - emptyMap(), - mapOf(), - mapOf(), - mapOf(), - DownloadModelsSize( - isAllBlocksDownloadedOrDownloading = false, - remainingCount = 0, - remainingSize = 0, - allCount = 0, - allSize = 0 - ), - useRelativeDates = true - ), - courseTitle = "", - onExpandClick = { }, - onSubSectionClick = { }, - videoSettings = VideoSettings.default, - onDownloadClick = {}, - onDownloadAllClick = {}, - onDownloadQueueClick = {}, - onVideoDownloadQualityClick = {} - ) - } -} - -private val mockAssignmentProgress = AssignmentProgress( - assignmentType = "Home", - numPointsEarned = 1f, - numPointsPossible = 3f -) - -private val mockChapterBlock = Block( - id = "id", - blockId = "blockId", - lmsWebUrl = "lmsWebUrl", - legacyWebUrl = "legacyWebUrl", - studentViewUrl = "studentViewUrl", - type = BlockType.CHAPTER, - displayName = "Chapter", - graded = false, - studentViewData = null, - studentViewMultiDevice = false, - blockCounts = BlockCounts(1), - descendants = emptyList(), - descendantsType = BlockType.CHAPTER, - completion = 0.0, - containsGatedContent = false, - assignmentProgress = mockAssignmentProgress, - due = Date(), - offlineDownload = null -) - -private val mockSequentialBlock = Block( - id = "id", - blockId = "blockId", - lmsWebUrl = "lmsWebUrl", - legacyWebUrl = "legacyWebUrl", - studentViewUrl = "studentViewUrl", - type = BlockType.SEQUENTIAL, - displayName = "Sequential", - graded = false, - studentViewData = null, - studentViewMultiDevice = false, - blockCounts = BlockCounts(1), - descendants = emptyList(), - descendantsType = BlockType.SEQUENTIAL, - completion = 0.0, - containsGatedContent = false, - assignmentProgress = mockAssignmentProgress, - due = Date(), - offlineDownload = null -) - -private val mockCourseStructure = CourseStructure( - root = "", - blockData = listOf(mockSequentialBlock, mockChapterBlock), - id = "id", - name = "Course name", - number = "", - org = "Org", - start = Date(), - startDisplay = "", - startType = "", - end = Date(), - coursewareAccess = CoursewareAccess( - true, - "", - "", - "", - "", - "" - ), - media = null, - certificate = null, - isSelfPaced = false, - progress = Progress(1, 3), -) diff --git a/course/src/main/java/org/openedx/course/presentation/unit/video/EncodedVideoUnitViewModel.kt b/course/src/main/java/org/openedx/course/presentation/unit/video/EncodedVideoUnitViewModel.kt index 08fde815b..76ded08a9 100644 --- a/course/src/main/java/org/openedx/course/presentation/unit/video/EncodedVideoUnitViewModel.kt +++ b/course/src/main/java/org/openedx/course/presentation/unit/video/EncodedVideoUnitViewModel.kt @@ -32,7 +32,8 @@ import java.util.concurrent.Executors @SuppressLint("StaticFieldLeak") class EncodedVideoUnitViewModel( courseId: String, - val blockId: String, + videoUrl: String, + blockId: String, private val context: Context, private val preferencesManager: CorePreferences, courseRepository: CourseRepository, @@ -42,6 +43,8 @@ class EncodedVideoUnitViewModel( courseAnalytics: CourseAnalytics, ) : VideoUnitViewModel( courseId, + videoUrl, + blockId, courseRepository, notifier, networkConnection, @@ -65,6 +68,11 @@ class EncodedVideoUnitViewModel( var isPlayerSetUp = false private val exoPlayerListener = object : Player.Listener { + override fun onRenderedFirstFrame() { + duration = exoPlayer?.duration ?: 0L + super.onRenderedFirstFrame() + } + override fun onPlayWhenReadyChanged(playWhenReady: Boolean, reason: Int) { super.onPlayWhenReadyChanged(playWhenReady, reason) isPlaying = playWhenReady diff --git a/course/src/main/java/org/openedx/course/presentation/unit/video/VideoFullScreenFragment.kt b/course/src/main/java/org/openedx/course/presentation/unit/video/VideoFullScreenFragment.kt index 745f3c67a..a0439d2ed 100644 --- a/course/src/main/java/org/openedx/course/presentation/unit/video/VideoFullScreenFragment.kt +++ b/course/src/main/java/org/openedx/course/presentation/unit/video/VideoFullScreenFragment.kt @@ -204,6 +204,7 @@ class VideoFullScreenFragment : Fragment(R.layout.fragment_video_full_screen) { override fun onDestroyView() { viewModel.currentVideoTime = exoPlayer?.currentPosition ?: C.TIME_UNSET + viewModel.duration = exoPlayer?.duration ?: 0L viewModel.sendTime() super.onDestroyView() } diff --git a/course/src/main/java/org/openedx/course/presentation/unit/video/VideoUnitFragment.kt b/course/src/main/java/org/openedx/course/presentation/unit/video/VideoUnitFragment.kt index e599b0f95..15725c19e 100644 --- a/course/src/main/java/org/openedx/course/presentation/unit/video/VideoUnitFragment.kt +++ b/course/src/main/java/org/openedx/course/presentation/unit/video/VideoUnitFragment.kt @@ -49,6 +49,7 @@ class VideoUnitFragment : Fragment(R.layout.fragment_video_unit) { private val viewModel by viewModel { parametersOf( requireArguments().getString(ARG_COURSE_ID, ""), + requireArguments().getString(ARG_VIDEO_URL, ""), requireArguments().getString(ARG_BLOCK_ID, ""), ) } @@ -79,7 +80,6 @@ class VideoUnitFragment : Fragment(R.layout.fragment_video_unit) { lifecycle.addObserver(viewModel) handler.post(videoTimeRunnable) requireArguments().apply { - viewModel.videoUrl = getString(ARG_VIDEO_URL, "") viewModel.transcripts = stringToObject>( getString(ARG_TRANSCRIPT_URL, "") ) ?: emptyMap() diff --git a/course/src/main/java/org/openedx/course/presentation/unit/video/VideoUnitViewModel.kt b/course/src/main/java/org/openedx/course/presentation/unit/video/VideoUnitViewModel.kt index 0360d9dc6..bd9199942 100644 --- a/course/src/main/java/org/openedx/course/presentation/unit/video/VideoUnitViewModel.kt +++ b/course/src/main/java/org/openedx/course/presentation/unit/video/VideoUnitViewModel.kt @@ -21,6 +21,8 @@ import subtitleFile.TimedTextObject open class VideoUnitViewModel( val courseId: String, + val videoUrl: String, + val blockId: String, private val courseRepository: CourseRepository, private val notifier: CourseNotifier, private val networkConnection: NetworkConnection, @@ -28,7 +30,6 @@ open class VideoUnitViewModel( courseAnalytics: CourseAnalytics, ) : BaseVideoViewModel(courseId, courseAnalytics) { - var videoUrl = "" var transcripts = emptyMap() var isPlaying = true var transcriptLanguage = AppDataConstants.defaultLocale.language ?: "en" @@ -40,6 +41,8 @@ open class VideoUnitViewModel( val currentVideoTime: LiveData get() = _currentVideoTime + var duration = 0L + protected val isUpdatedMutable = MutableLiveData(true) val isUpdated: LiveData get() = isUpdatedMutable @@ -58,6 +61,10 @@ open class VideoUnitViewModel( private var isBlockAlreadyCompleted = false + init { + initVideoProgress() + } + override fun onCreate(owner: LifecycleOwner) { super.onCreate(owner) viewModelScope.launch { @@ -65,6 +72,7 @@ open class VideoUnitViewModel( if (it is CourseVideoPositionChanged && videoUrl == it.videoUrl) { isUpdatedMutable.value = false _currentVideoTime.value = it.videoTime + saveVideoProgress() isUpdatedMutable.value = true isPlaying = it.isPlaying } else if (it is CourseSubtitleLanguageChanged) { @@ -76,6 +84,22 @@ open class VideoUnitViewModel( } } + override fun onPause(owner: LifecycleOwner) { + saveVideoProgress() + super.onPause(owner) + } + + private fun saveVideoProgress() { + viewModelScope.launch { + courseRepository.saveVideoProgress( + blockId, + videoUrl, + _currentVideoTime.value ?: 0L, + duration + ) + } + } + fun downloadSubtitles() { viewModelScope.launch(Dispatchers.IO) { transcriptManager.downloadTranscriptsForVideo(getTranscriptUrl())?.let { result -> @@ -131,4 +155,15 @@ open class VideoUnitViewModel( } fun getCurrentVideoTime() = currentVideoTime.value ?: 0 + + private fun initVideoProgress() { + viewModelScope.launch { + try { + val videoProgress = courseRepository.getVideoProgress(blockId) + _currentVideoTime.value = videoProgress.videoTime + } catch (e: Exception) { + e.printStackTrace() + } + } + } } diff --git a/course/src/main/java/org/openedx/course/presentation/unit/video/VideoViewModel.kt b/course/src/main/java/org/openedx/course/presentation/unit/video/VideoViewModel.kt index 423c825ce..c9da7aaec 100644 --- a/course/src/main/java/org/openedx/course/presentation/unit/video/VideoViewModel.kt +++ b/course/src/main/java/org/openedx/course/presentation/unit/video/VideoViewModel.kt @@ -20,6 +20,7 @@ class VideoViewModel( var videoUrl = "" var currentVideoTime = 0L + var duration = 0L var isPlaying: Boolean? = null private var isBlockAlreadyCompleted = false @@ -31,7 +32,8 @@ class VideoViewModel( CourseVideoPositionChanged( videoUrl, currentVideoTime, - isPlaying ?: false + duration, + isPlaying == true ) ) } diff --git a/course/src/main/java/org/openedx/course/presentation/unit/video/YoutubeVideoFullScreenFragment.kt b/course/src/main/java/org/openedx/course/presentation/unit/video/YoutubeVideoFullScreenFragment.kt index 03f8b906a..0f4a75697 100644 --- a/course/src/main/java/org/openedx/course/presentation/unit/video/YoutubeVideoFullScreenFragment.kt +++ b/course/src/main/java/org/openedx/course/presentation/unit/video/YoutubeVideoFullScreenFragment.kt @@ -124,6 +124,11 @@ class YoutubeVideoFullScreenFragment : Fragment(R.layout.fragment_youtube_video_ } youTubePlayer.addListener(youtubeTrackerListener) } + + override fun onVideoDuration(youTubePlayer: YouTubePlayer, duration: Float) { + viewModel.duration = (duration * 1000).toLong() + super.onVideoDuration(youTubePlayer, duration) + } }, options ) diff --git a/course/src/main/java/org/openedx/course/presentation/unit/video/YoutubeVideoUnitFragment.kt b/course/src/main/java/org/openedx/course/presentation/unit/video/YoutubeVideoUnitFragment.kt index c1cd33aa3..1afe71e91 100644 --- a/course/src/main/java/org/openedx/course/presentation/unit/video/YoutubeVideoUnitFragment.kt +++ b/course/src/main/java/org/openedx/course/presentation/unit/video/YoutubeVideoUnitFragment.kt @@ -39,7 +39,11 @@ import org.openedx.foundation.presentation.WindowSize class YoutubeVideoUnitFragment : Fragment(R.layout.fragment_youtube_video_unit) { private val viewModel by viewModel { - parametersOf(requireArguments().getString(ARG_COURSE_ID, "")) + parametersOf( + requireArguments().getString(ARG_COURSE_ID, ""), + requireArguments().getString(ARG_VIDEO_URL, ""), + requireArguments().getString(ARG_BLOCK_ID, ""), + ) } private val router by inject() private val appReviewManager by inject { parametersOf(requireActivity()) } @@ -61,7 +65,6 @@ class YoutubeVideoUnitFragment : Fragment(R.layout.fragment_youtube_video_unit) windowSize = computeWindowSizeClasses() lifecycle.addObserver(viewModel) requireArguments().apply { - viewModel.videoUrl = getString(ARG_VIDEO_URL, "") viewModel.transcripts = stringToObject>( getString(ARG_TRANSCRIPT_URL, "") ) ?: emptyMap() @@ -220,6 +223,11 @@ class YoutubeVideoUnitFragment : Fragment(R.layout.fragment_youtube_video_unit) CourseAnalyticsKey.YOUTUBE.key ) } + + override fun onVideoDuration(youTubePlayer: YouTubePlayer, duration: Float) { + viewModel.duration = (duration * 1000).toLong() + super.onVideoDuration(youTubePlayer, duration) + } } if (!isPlayerInitialized) { diff --git a/course/src/main/java/org/openedx/course/presentation/videos/CourseContentVideoScreen.kt b/course/src/main/java/org/openedx/course/presentation/videos/CourseContentVideoScreen.kt new file mode 100644 index 000000000..571fde683 --- /dev/null +++ b/course/src/main/java/org/openedx/course/presentation/videos/CourseContentVideoScreen.kt @@ -0,0 +1,390 @@ +package org.openedx.course.presentation.videos + +import android.content.res.Configuration +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +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.foundation.lazy.LazyColumn +import androidx.compose.material.Divider +import androidx.compose.material.MaterialTheme +import androidx.compose.material.Scaffold +import androidx.compose.material.Surface +import androidx.compose.material.rememberScaffoldState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Devices +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import androidx.fragment.app.FragmentManager +import 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.CourseStructure +import org.openedx.core.domain.model.CoursewareAccess +import org.openedx.core.domain.model.Progress +import org.openedx.core.module.download.DownloadModelsSize +import org.openedx.core.presentation.course.CourseViewMode +import org.openedx.core.ui.CircularProgress +import org.openedx.core.ui.HandleUIMessage +import org.openedx.core.ui.displayCutoutForLandscape +import org.openedx.core.ui.theme.OpenEdXTheme +import org.openedx.core.ui.theme.appColors +import org.openedx.course.R +import org.openedx.course.presentation.contenttab.CourseContentVideoEmptyState +import org.openedx.course.presentation.ui.CourseProgress +import org.openedx.course.presentation.ui.CourseVideoSection +import org.openedx.foundation.presentation.UIMessage +import org.openedx.foundation.presentation.WindowSize +import org.openedx.foundation.presentation.WindowType +import org.openedx.foundation.presentation.windowSizeValue +import java.util.Date + +@Composable +fun CourseContentVideoScreen( + windowSize: WindowSize, + viewModel: CourseVideoViewModel, + fragmentManager: FragmentManager, + onNavigateToHome: () -> Unit = {}, +) { + val uiState by viewModel.uiState.collectAsState(CourseVideoUIState.Loading) + val uiMessage by viewModel.uiMessage.collectAsState(null) + + CourseVideosUI( + windowSize = windowSize, + uiState = uiState, + uiMessage = uiMessage, + onNavigateToHome = onNavigateToHome, + onVideoClick = { videoBlock -> + viewModel.courseRouter.navigateToCourseContainer( + fragmentManager, + courseId = viewModel.courseId, + unitId = viewModel.getBlockParent(videoBlock.id)?.id ?: return@CourseVideosUI, + mode = CourseViewMode.VIDEOS + ) + viewModel.logVideoClick(videoBlock.id) + }, + onDownloadClick = { blocksIds -> + viewModel.downloadBlocks( + blocksIds = blocksIds, + fragmentManager = fragmentManager, + ) + }, + onCompletedSectionVisibilityChange = { + viewModel.onCompletedSectionVisibilityChange() + }, + ) +} + +@Composable +private fun CourseVideosUI( + windowSize: WindowSize, + uiState: CourseVideoUIState, + uiMessage: UIMessage?, + onNavigateToHome: () -> Unit, + onVideoClick: (Block) -> Unit, + onDownloadClick: (blocksIds: List) -> Unit, + onCompletedSectionVisibilityChange: () -> Unit, +) { + val scaffoldState = rememberScaffoldState() + + Scaffold( + modifier = Modifier.fillMaxSize(), + scaffoldState = scaffoldState, + backgroundColor = MaterialTheme.appColors.background + ) { + val screenWidth by remember(key1 = windowSize) { + mutableStateOf( + windowSize.windowSizeValue( + expanded = Modifier.widthIn(Dp.Unspecified, 560.dp), + compact = Modifier.fillMaxWidth() + ) + ) + } + + val listBottomPadding by remember(key1 = windowSize) { + mutableStateOf( + windowSize.windowSizeValue( + expanded = PaddingValues(bottom = 24.dp), + compact = PaddingValues(bottom = 24.dp) + ) + ) + } + + HandleUIMessage(uiMessage = uiMessage, scaffoldState = scaffoldState) + + Box( + modifier = Modifier + .fillMaxSize() + .padding(it) + .displayCutoutForLandscape(), + contentAlignment = Alignment.TopCenter + ) { + Surface( + modifier = screenWidth, + color = MaterialTheme.appColors.background + ) { + Box { + Column( + modifier = Modifier.fillMaxSize() + ) { + when (uiState) { + is CourseVideoUIState.Empty -> { + CourseContentVideoEmptyState( + onReturnToCourseClick = onNavigateToHome + ) + } + + is CourseVideoUIState.CourseData -> { + LazyColumn( + modifier = Modifier.fillMaxSize(), + contentPadding = listBottomPadding + ) { + val allVideos = uiState.courseVideos.values.flatten() + val hasCompletedSection = + uiState.courseVideos.values.any { sectionVideos -> + sectionVideos.all { video -> + video.isCompleted() + } + } + val progress = Progress( + completed = allVideos.filter { it.isCompleted() }.size, + total = allVideos.size, + ) + item { + CourseProgress( + modifier = Modifier + .fillMaxWidth() + .padding( + bottom = 8.dp, + start = 24.dp, + end = 24.dp, + ), + progress = progress, + isCompletedShown = uiState.isCompletedSectionsShown, + onVisibilityChanged = if (hasCompletedSection) { + { onCompletedSectionVisibilityChange() } + } else { + null + }, + description = stringResource( + R.string.course_completed, + progress.completed, + progress.total + ) + ) + } + item { + Divider(modifier = Modifier.fillMaxWidth()) + } + + uiState.courseStructure.blockData + .let { list -> + if (uiState.isCompletedSectionsShown) { + list.sortedBy { section -> + uiState.courseVideos[section.id]?.any { !it.isCompleted() } + } + } else { + list + } + } + .forEach { section -> + val sectionVideos = + uiState.courseVideos[section.id] ?: emptyList() + + val shouldShowSection = + sectionVideos.any { !it.isCompleted() } || + uiState.isCompletedSectionsShown + if (shouldShowSection) { + item { + CourseVideoSection( + block = section, + videoBlocks = sectionVideos, + downloadedStateMap = uiState.downloadedState, + onVideoClick = onVideoClick, + onDownloadClick = onDownloadClick, + preview = uiState.videoPreview, + progress = uiState.videoProgress, + ) + } + } + } + } + } + + CourseVideoUIState.Loading -> { + CircularProgress() + } + } + } + } + } + } + } +} + +@Preview(uiMode = Configuration.UI_MODE_NIGHT_NO) +@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES) +@Composable +private fun CourseVideosScreenPreview() { + OpenEdXTheme { + CourseVideosUI( + windowSize = WindowSize(WindowType.Compact, WindowType.Compact), + uiMessage = null, + uiState = CourseVideoUIState.CourseData( + mockCourseStructure, + emptyMap(), + mapOf(), + mapOf(), + DownloadModelsSize( + isAllBlocksDownloadedOrDownloading = false, + remainingCount = 0, + remainingSize = 0, + allCount = 1, + allSize = 0 + ), + isCompletedSectionsShown = false, + videoPreview = mapOf(), + videoProgress = mapOf(), + ), + onVideoClick = { }, + onDownloadClick = {}, + onCompletedSectionVisibilityChange = {}, + onNavigateToHome = {}, + ) + } +} + +@Preview(uiMode = Configuration.UI_MODE_NIGHT_NO) +@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES) +@Composable +private fun CourseVideosScreenEmptyPreview() { + OpenEdXTheme { + CourseVideosUI( + windowSize = WindowSize(WindowType.Compact, WindowType.Compact), + uiMessage = null, + uiState = CourseVideoUIState.Empty, + onVideoClick = { }, + onDownloadClick = {}, + onCompletedSectionVisibilityChange = {}, + onNavigateToHome = {}, + ) + } +} + +@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 CourseVideosScreenTabletPreview() { + OpenEdXTheme { + CourseVideosUI( + windowSize = WindowSize(WindowType.Medium, WindowType.Medium), + uiMessage = null, + uiState = CourseVideoUIState.CourseData( + mockCourseStructure, + emptyMap(), + mapOf(), + mapOf(), + DownloadModelsSize( + isAllBlocksDownloadedOrDownloading = false, + remainingCount = 0, + remainingSize = 0, + allCount = 0, + allSize = 0 + ), + isCompletedSectionsShown = true, + videoPreview = mapOf(), + videoProgress = mapOf(), + ), + onVideoClick = { }, + onDownloadClick = {}, + onCompletedSectionVisibilityChange = {}, + onNavigateToHome = {}, + ) + } +} + +private val mockAssignmentProgress = AssignmentProgress( + assignmentType = "Home", + numPointsEarned = 1f, + numPointsPossible = 3f, + shortLabel = "HM1" +) + +private val mockChapterBlock = Block( + id = "id", + blockId = "blockId", + lmsWebUrl = "lmsWebUrl", + legacyWebUrl = "legacyWebUrl", + studentViewUrl = "studentViewUrl", + type = BlockType.CHAPTER, + displayName = "Chapter", + graded = false, + studentViewData = null, + studentViewMultiDevice = false, + blockCounts = BlockCounts(1), + descendants = emptyList(), + descendantsType = BlockType.CHAPTER, + completion = 0.0, + containsGatedContent = false, + assignmentProgress = mockAssignmentProgress, + due = Date(), + offlineDownload = null +) + +private val mockSequentialBlock = Block( + id = "id", + blockId = "blockId", + lmsWebUrl = "lmsWebUrl", + legacyWebUrl = "legacyWebUrl", + studentViewUrl = "studentViewUrl", + type = BlockType.SEQUENTIAL, + displayName = "Sequential", + graded = false, + studentViewData = null, + studentViewMultiDevice = false, + blockCounts = BlockCounts(1), + descendants = emptyList(), + descendantsType = BlockType.SEQUENTIAL, + completion = 0.0, + containsGatedContent = false, + assignmentProgress = mockAssignmentProgress, + due = Date(), + offlineDownload = null +) + +private val mockCourseStructure = CourseStructure( + root = "", + blockData = listOf(mockSequentialBlock, mockChapterBlock), + id = "id", + name = "Course name", + number = "", + org = "Org", + start = Date(), + startDisplay = "", + startType = "", + end = Date(), + coursewareAccess = CoursewareAccess( + true, + "", + "", + "", + "", + "" + ), + media = null, + certificate = null, + isSelfPaced = false, + progress = Progress(1, 3), +) diff --git a/course/src/main/java/org/openedx/course/presentation/videos/CourseVideosUIState.kt b/course/src/main/java/org/openedx/course/presentation/videos/CourseVideoUIState.kt similarity index 55% rename from course/src/main/java/org/openedx/course/presentation/videos/CourseVideosUIState.kt rename to course/src/main/java/org/openedx/course/presentation/videos/CourseVideoUIState.kt index 245fb2380..61f1c9283 100644 --- a/course/src/main/java/org/openedx/course/presentation/videos/CourseVideosUIState.kt +++ b/course/src/main/java/org/openedx/course/presentation/videos/CourseVideoUIState.kt @@ -4,18 +4,20 @@ import org.openedx.core.domain.model.Block import org.openedx.core.domain.model.CourseStructure import org.openedx.core.module.db.DownloadedState import org.openedx.core.module.download.DownloadModelsSize +import org.openedx.core.utils.VideoPreview -sealed class CourseVideosUIState { +sealed class CourseVideoUIState { data class CourseData( val courseStructure: CourseStructure, val downloadedState: Map, - val courseSubSections: Map>, - val courseSectionsState: Map, + val courseVideos: Map>, val subSectionsDownloadsCount: Map, val downloadModelsSize: DownloadModelsSize, - val useRelativeDates: Boolean - ) : CourseVideosUIState() + val isCompletedSectionsShown: Boolean, + val videoPreview: Map, + val videoProgress: Map, + ) : CourseVideoUIState() - data object Empty : CourseVideosUIState() - data object Loading : CourseVideosUIState() + data object Empty : CourseVideoUIState() + data object Loading : CourseVideoUIState() } diff --git a/course/src/main/java/org/openedx/course/presentation/videos/CourseVideoViewModel.kt b/course/src/main/java/org/openedx/course/presentation/videos/CourseVideoViewModel.kt index 242b667b7..c37b8709e 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,7 +1,10 @@ package org.openedx.course.presentation.videos +import android.annotation.SuppressLint +import android.content.Context import androidx.fragment.app.FragmentManager import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharedFlow @@ -9,11 +12,12 @@ import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext import org.openedx.core.BlockType import org.openedx.core.config.Config import org.openedx.core.data.storage.CorePreferences import org.openedx.core.domain.model.Block -import org.openedx.core.domain.model.VideoSettings +import org.openedx.core.extension.safeDivBy import org.openedx.core.module.DownloadWorkerController import org.openedx.core.module.db.DownloadDao import org.openedx.core.module.download.BaseDownloadViewModel @@ -24,30 +28,30 @@ import org.openedx.core.system.connection.NetworkConnection import org.openedx.core.system.notifier.CourseLoading import org.openedx.core.system.notifier.CourseNotifier import org.openedx.core.system.notifier.CourseStructureUpdated -import org.openedx.core.system.notifier.VideoNotifier -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.CourseAnalyticsEvent +import org.openedx.course.presentation.CourseAnalyticsKey import org.openedx.course.presentation.CourseRouter import org.openedx.foundation.presentation.UIMessage import org.openedx.foundation.system.ResourceManager import org.openedx.foundation.utils.FileUtil +@SuppressLint("StaticFieldLeak") class CourseVideoViewModel( val courseId: String, - val courseTitle: String, + private val context: Context, private val config: Config, private val interactor: CourseInteractor, private val resourceManager: ResourceManager, private val networkConnection: NetworkConnection, private val preferencesManager: CorePreferences, private val courseNotifier: CourseNotifier, - private val videoNotifier: VideoNotifier, - private val analytics: CourseAnalytics, private val downloadDialogManager: DownloadDialogManager, private val fileUtil: FileUtil, val courseRouter: CourseRouter, + private val analytics: CourseAnalytics, coreAnalytics: CoreAnalytics, downloadDao: DownloadDao, workerController: DownloadWorkerController, @@ -59,20 +63,15 @@ class CourseVideoViewModel( coreAnalytics, downloadHelper, ) { - - val isCourseDropdownNavigationEnabled get() = config.getCourseUIConfig().isCourseDropdownNavigationEnabled - - private val _uiState = MutableStateFlow(CourseVideosUIState.Loading) - val uiState: StateFlow + private val _uiState = MutableStateFlow(CourseVideoUIState.Loading) + val uiState: StateFlow get() = _uiState.asStateFlow() private val _uiMessage = MutableSharedFlow() val uiMessage: SharedFlow get() = _uiMessage.asSharedFlow() - private val _videoSettings = MutableStateFlow(VideoSettings.default) - val videoSettings = _videoSettings.asStateFlow() - + private val courseVideos = mutableMapOf>() private val courseSubSections = mutableMapOf>() private val subSectionsDownloadsCount = mutableMapOf() val courseSubSectionUnit = mutableMapOf() @@ -92,8 +91,8 @@ class CourseVideoViewModel( viewModelScope.launch { downloadModelsStatusFlow.collect { - if (_uiState.value is CourseVideosUIState.CourseData) { - val state = _uiState.value as CourseVideosUIState.CourseData + if (_uiState.value is CourseVideoUIState.CourseData) { + val state = _uiState.value as CourseVideoUIState.CourseData _uiState.value = state.copy( downloadedState = it.toMap(), downloadModelsSize = getDownloadModelsSize() @@ -102,23 +101,6 @@ class CourseVideoViewModel( } } - viewModelScope.launch { - videoNotifier.notifier.collect { event -> - if (event is VideoQualityChanged) { - _videoSettings.value = preferencesManager.videoSettings - - if (_uiState.value is CourseVideosUIState.CourseData) { - val state = _uiState.value as CourseVideosUIState.CourseData - _uiState.value = state.copy( - downloadModelsSize = getDownloadModelsSize() - ) - } - } - } - } - - _videoSettings.value = preferencesManager.videoSettings - getVideos() } @@ -159,68 +141,53 @@ class CourseVideoViewModel( var courseStructure = interactor.getCourseStructureForVideos(courseId) val blocks = courseStructure.blockData if (blocks.isEmpty()) { - _uiState.value = CourseVideosUIState.Empty + _uiState.value = CourseVideoUIState.Empty } else { setBlocks(courseStructure.blockData) - courseSubSections.clear() + courseVideos.clear() courseSubSectionUnit.clear() courseStructure = courseStructure.copy(blockData = sortBlocks(blocks)) initDownloadModelsStatus() - - val courseSectionsState = - (_uiState.value as? CourseVideosUIState.CourseData)?.courseSectionsState.orEmpty() + val downloadingModels = getDownloadModelList() + val videoPreview = withContext(Dispatchers.IO) { + courseVideos.values.flatten().associate { block -> + block.id to block.getVideoPreview( + context, + networkConnection.isOnline(), + downloadingModels.find { block.id == it.id }?.path + ) + } + } + val videoProgress = courseVideos.values.flatten().associate { block -> + val videoProgressEntity = interactor.getVideoProgress(block.id) + val progress = videoProgressEntity.videoTime.toFloat() + .safeDivBy(videoProgressEntity.duration.toFloat()) + block.id to progress + } + val isCompletedSectionsShown = + (_uiState.value as? CourseVideoUIState.CourseData)?.isCompletedSectionsShown + ?: false _uiState.value = - CourseVideosUIState.CourseData( + CourseVideoUIState.CourseData( courseStructure = courseStructure, downloadedState = getDownloadModelsStatus(), - courseSubSections = courseSubSections, - courseSectionsState = courseSectionsState, + courseVideos = courseVideos, subSectionsDownloadsCount = subSectionsDownloadsCount, downloadModelsSize = getDownloadModelsSize(), - useRelativeDates = preferencesManager.isRelativeDatesEnabled + isCompletedSectionsShown = isCompletedSectionsShown, + videoPreview = videoPreview, + videoProgress = videoProgress, ) } courseNotifier.send(CourseLoading(false)) } catch (e: Exception) { e.printStackTrace() - _uiState.value = CourseVideosUIState.Empty + _uiState.value = CourseVideoUIState.Empty } } } - fun switchCourseSections(blockId: String) { - if (_uiState.value is CourseVideosUIState.CourseData) { - val state = _uiState.value as CourseVideosUIState.CourseData - val courseSectionsState = state.courseSectionsState.toMutableMap() - courseSectionsState[blockId] = !(state.courseSectionsState[blockId] ?: false) - - _uiState.value = state.copy(courseSectionsState = courseSectionsState) - } - } - - fun sequentialClickedEvent(blockId: String, blockName: String) { - val currentState = uiState.value - if (currentState is CourseVideosUIState.CourseData) { - analytics.sequentialClickedEvent( - courseId, - courseTitle, - blockId, - blockName - ) - } - } - - fun onChangingVideoQualityWhileDownloading() { - viewModelScope.launch { - _uiMessage.emit( - UIMessage.SnackBarMessage( - resourceManager.getString(R.string.course_change_quality_when_downloading) - ) - ) - } - } - private fun sortBlocks(blocks: List): List { if (blocks.isEmpty()) return emptyList() @@ -237,7 +204,14 @@ class CourseVideoViewModel( private fun processDescendants(chapterBlock: Block, blocks: List) { chapterBlock.descendants.forEach { descendantId -> val sequentialBlock = blocks.find { it.id == descendantId } ?: return@forEach + val verticalBlocks = blocks.filter { block -> + block.id in sequentialBlock.descendants + } + val videoBlocks = blocks.filter { block -> + verticalBlocks.any { vertical -> block.id in vertical.descendants } && block.type == BlockType.VIDEO + } addToSubSections(chapterBlock, sequentialBlock) + addToVideo(chapterBlock, videoBlocks) updateSubSectionUnit(sequentialBlock, blocks) updateDownloadsCount(sequentialBlock, blocks) addDownloadableChildrenForSequentialBlock(sequentialBlock) @@ -248,6 +222,10 @@ class CourseVideoViewModel( courseSubSections.getOrPut(chapterBlock.id) { mutableListOf() }.add(sequentialBlock) } + private fun addToVideo(chapterBlock: Block, videoBlocks: List) { + courseVideos.getOrPut(chapterBlock.id) { mutableListOf() }.addAll(videoBlocks) + } + private fun updateSubSectionUnit(sequentialBlock: Block, blocks: List) { courseSubSectionUnit[sequentialBlock.id] = sequentialBlock.getFirstDescendantBlock(blocks) } @@ -258,10 +236,8 @@ class CourseVideoViewModel( fun downloadBlocks(blocksIds: List, fragmentManager: FragmentManager) { viewModelScope.launch { - val courseData = _uiState.value as? CourseVideosUIState.CourseData ?: return@launch - val subSectionsBlocks = - courseData.courseSubSections.values.flatten().filter { it.id in blocksIds } + courseSubSections.values.flatten().filter { it.id in blocksIds } val blocks = subSectionsBlocks.flatMap { subSectionsBlock -> val verticalBlocks = @@ -315,4 +291,42 @@ class CourseVideoViewModel( } } } + + fun onCompletedSectionVisibilityChange() { + if (_uiState.value is CourseVideoUIState.CourseData) { + val state = _uiState.value as CourseVideoUIState.CourseData + _uiState.value = state.copy(isCompletedSectionsShown = !state.isCompletedSectionsShown) + + analytics.logEvent( + CourseAnalyticsEvent.VIDEO_SHOW_COMPLETED.eventName, + buildMap { + put( + CourseAnalyticsKey.NAME.key, + CourseAnalyticsEvent.VIDEO_SHOW_COMPLETED.biValue + ) + put(CourseAnalyticsKey.COURSE_ID.key, courseId) + } + ) + } + } + + fun logVideoClick(blockId: String) { + if (_uiState.value is CourseVideoUIState.CourseData) { + analytics.logEvent( + CourseAnalyticsEvent.COURSE_CONTENT_VIDEO_CLICK.eventName, + buildMap { + put( + CourseAnalyticsKey.NAME.key, + CourseAnalyticsEvent.COURSE_CONTENT_VIDEO_CLICK.biValue + ) + put(CourseAnalyticsKey.COURSE_ID.key, courseId) + put(CourseAnalyticsKey.BLOCK_ID.key, blockId) + } + ) + } + } + + fun getBlockParent(blockId: String): Block? { + return allBlocks.values.find { blockId in it.descendants } + } } diff --git a/course/src/main/res/drawable/course_ic_warning.xml b/course/src/main/res/drawable/course_ic_warning.xml new file mode 100644 index 000000000..635c5ca80 --- /dev/null +++ b/course/src/main/res/drawable/course_ic_warning.xml @@ -0,0 +1,9 @@ + + + diff --git a/course/src/main/res/drawable/course_video_play_button.xml b/course/src/main/res/drawable/course_video_play_button.xml new file mode 100644 index 000000000..ce96d5ec1 --- /dev/null +++ b/course/src/main/res/drawable/course_video_play_button.xml @@ -0,0 +1,12 @@ + + + + diff --git a/course/src/main/res/values/strings.xml b/course/src/main/res/values/strings.xml index 47374e30f..33852c242 100644 --- a/course/src/main/res/values/strings.xml +++ b/course/src/main/res/values/strings.xml @@ -19,8 +19,6 @@ Explore other parts of this course or view this on web. Open in browser Subtitles - Continue with: - Resume To proceed with \"%s\" press \"Next section\". Some content in this part of the course is locked for upgraded users only. You cannot change the download video quality when all videos are downloading @@ -35,16 +33,18 @@ Assignment Type Current / Max % - - Home - Videos + Content Discussions More Dates Downloads Progress + All + Videos + Assignments + Video player Remove course section @@ -52,19 +52,33 @@ Stop downloading course section Section completed Section uncompleted + Video watched + Assignment completed + Assignment due date past - - %1$s of %2$s assignment complete - %1$s of %2$s assignments complete + + %1$s/%2$s Section Completed + %1$s/%2$s Sections Completed Back Your free audit access to this course expired on %s. This course will begin on %s. Come back then to start learning! An error occurred while loading your course + Continue + View Completed + Hide Completed Completed - %1$s / %2$s Complete - of Grade - %1$s / %2$s%% + %1$s / %2$s Complete + of Grade + %1$s / %2$s%% This course does not contain graded assignments. + %1$s/%2$s Watched + %1$s/%2$s Completed + Complete - %1$s points + Past Due - %1$s points + In Progress - %1$s points + %1$s %% of Grade + Review Course Grading Policy + Return to Course Home diff --git a/course/src/test/java/org/openedx/course/presentation/outline/CourseOutlineViewModelTest.kt b/course/src/test/java/org/openedx/course/presentation/outline/CourseOutlineViewModelTest.kt index f4e21f843..62fc097b7 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 @@ -95,7 +95,8 @@ class CourseOutlineViewModelTest { private val assignmentProgress = AssignmentProgress( assignmentType = "Homework", numPointsEarned = 1f, - numPointsPossible = 3f + numPointsPossible = 3f, + shortLabel = "HW1", ) private val blocks = listOf( @@ -271,7 +272,7 @@ class CourseOutlineViewModelTest { } returns Unit coEvery { interactor.getCourseStatusFlow(any()) } returns flow { throw UnknownHostException() } - val viewModel = CourseOutlineViewModel( + val viewModel = CourseContentAllViewModel( "", "", config, @@ -300,7 +301,7 @@ class CourseOutlineViewModelTest { coVerify(exactly = 2) { interactor.getCourseStatusFlow(any()) } assertEquals(noInternet, message.await()?.message) - assert(viewModel.uiState.value is CourseOutlineUIState.Error) + assert(viewModel.uiState.value is CourseContentAllUIState.Error) } @Suppress("TooGenericExceptionThrown") @@ -310,7 +311,7 @@ class CourseOutlineViewModelTest { every { networkConnection.isOnline() } returns true every { downloadDao.getAllDataFlow() } returns flow { emit(emptyList()) } coEvery { interactor.getCourseStatusFlow(any()) } returns flow { throw Exception() } - val viewModel = CourseOutlineViewModel( + val viewModel = CourseContentAllViewModel( "", "", config, @@ -339,7 +340,7 @@ class CourseOutlineViewModelTest { coVerify(exactly = 2) { interactor.getCourseStatusFlow(any()) } assertEquals(somethingWrong, message.await()?.message) - assert(viewModel.uiState.value is CourseOutlineUIState.Error) + assert(viewModel.uiState.value is CourseContentAllUIState.Error) } @Test @@ -361,7 +362,7 @@ class CourseOutlineViewModelTest { coEvery { interactor.getCourseStatusFlow(any()) } returns flowOf(CourseComponentStatus("id")) every { config.getCourseUIConfig().isCourseDropdownNavigationEnabled } returns false - val viewModel = CourseOutlineViewModel( + val viewModel = CourseContentAllViewModel( "", "", config, @@ -393,7 +394,7 @@ class CourseOutlineViewModelTest { coVerify(exactly = 2) { interactor.getCourseStatusFlow(any()) } assert(message.await() == null) - assert(viewModel.uiState.value is CourseOutlineUIState.CourseData) + assert(viewModel.uiState.value is CourseContentAllUIState.CourseData) } @Test @@ -415,7 +416,7 @@ class CourseOutlineViewModelTest { coEvery { interactor.getCourseStatusFlow(any()) } returns flowOf(CourseComponentStatus("id")) every { config.getCourseUIConfig().isCourseDropdownNavigationEnabled } returns false - val viewModel = CourseOutlineViewModel( + val viewModel = CourseContentAllViewModel( "", "", config, @@ -446,7 +447,7 @@ class CourseOutlineViewModelTest { coVerify(exactly = 2) { interactor.getCourseStatusFlow(any()) } assert(message.await() == null) - assert(viewModel.uiState.value is CourseOutlineUIState.CourseData) + assert(viewModel.uiState.value is CourseContentAllUIState.CourseData) } @Test @@ -468,7 +469,7 @@ class CourseOutlineViewModelTest { coEvery { interactor.getCourseStatusFlow(any()) } returns flowOf(CourseComponentStatus("id")) every { config.getCourseUIConfig().isCourseDropdownNavigationEnabled } returns false - val viewModel = CourseOutlineViewModel( + val viewModel = CourseContentAllViewModel( "", "", config, @@ -499,7 +500,7 @@ class CourseOutlineViewModelTest { coVerify(exactly = 2) { interactor.getCourseStatusFlow(any()) } assert(message.await() == null) - assert(viewModel.uiState.value is CourseOutlineUIState.CourseData) + assert(viewModel.uiState.value is CourseContentAllUIState.CourseData) } @Test @@ -510,7 +511,7 @@ class CourseOutlineViewModelTest { every { networkConnection.isOnline() } returns true coEvery { interactor.getCourseStatusFlow(any()) } returns flowOf(CourseComponentStatus("id")) - val viewModel = CourseOutlineViewModel( + val viewModel = CourseContentAllViewModel( "", "", config, @@ -560,7 +561,7 @@ class CourseOutlineViewModelTest { coEvery { downloadDao.getAllDataFlow() } returns flow { emit(emptyList()) } every { config.getCourseUIConfig().isCourseDropdownNavigationEnabled } returns false - val viewModel = CourseOutlineViewModel( + val viewModel = CourseContentAllViewModel( "", "", config, @@ -612,7 +613,7 @@ class CourseOutlineViewModelTest { every { config.getCourseUIConfig().isCourseDropdownNavigationEnabled } returns false every { coreAnalytics.logEvent(any(), any()) } returns Unit - val viewModel = CourseOutlineViewModel( + val viewModel = CourseContentAllViewModel( "", "", config, 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 02eda9622..7336e9307 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 @@ -73,7 +73,8 @@ class CourseSectionViewModelTest { private val assignmentProgress = AssignmentProgress( assignmentType = "Homework", numPointsEarned = 1f, - numPointsPossible = 3f + numPointsPossible = 3f, + shortLabel = "HW1", ) private val blocks = listOf( 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 9d0f0c7c1..becf35187 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 @@ -50,7 +50,8 @@ class CourseUnitContainerViewModelTest { private val assignmentProgress = AssignmentProgress( assignmentType = "Homework", numPointsEarned = 1f, - numPointsPossible = 3f + numPointsPossible = 3f, + shortLabel = "HW1", ) private val blocks = listOf( diff --git a/course/src/test/java/org/openedx/course/presentation/unit/video/VideoUnitViewModelTest.kt b/course/src/test/java/org/openedx/course/presentation/unit/video/VideoUnitViewModelTest.kt index effd426a0..1d8524a7b 100644 --- a/course/src/test/java/org/openedx/course/presentation/unit/video/VideoUnitViewModelTest.kt +++ b/course/src/test/java/org/openedx/course/presentation/unit/video/VideoUnitViewModelTest.kt @@ -57,6 +57,8 @@ class VideoUnitViewModelTest { @Test fun `markBlockCompleted exception`() = runTest { val viewModel = VideoUnitViewModel( + "", + "", "", courseRepository, notifier, @@ -96,6 +98,8 @@ class VideoUnitViewModelTest { @Test fun `markBlockCompleted success`() = runTest { val viewModel = VideoUnitViewModel( + "", + "", "", courseRepository, notifier, @@ -135,6 +139,8 @@ class VideoUnitViewModelTest { @Test fun `CourseVideoPositionChanged notifier test`() = runTest { val viewModel = VideoUnitViewModel( + "", + "", "", courseRepository, notifier, @@ -147,10 +153,12 @@ class VideoUnitViewModelTest { CourseVideoPositionChanged( "", 10, - false + 10000L, + false, ) ) } + coEvery { courseRepository.saveVideoProgress(any(), any(), any(), any()) } returns Unit val mockLifeCycleOwner: LifecycleOwner = mockk() val lifecycleRegistry = LifecycleRegistry(mockLifeCycleOwner) lifecycleRegistry.addObserver(viewModel) diff --git a/course/src/test/java/org/openedx/course/presentation/unit/video/VideoViewModelTest.kt b/course/src/test/java/org/openedx/course/presentation/unit/video/VideoViewModelTest.kt index ad04283d7..ae954c5f7 100644 --- a/course/src/test/java/org/openedx/course/presentation/unit/video/VideoViewModelTest.kt +++ b/course/src/test/java/org/openedx/course/presentation/unit/video/VideoViewModelTest.kt @@ -52,11 +52,11 @@ class VideoViewModelTest { fun `sendTime test`() = runTest { val viewModel = VideoViewModel("", courseRepository, notifier, preferenceManager, courseAnalytics) - coEvery { notifier.send(CourseVideoPositionChanged("", 0, false)) } returns Unit + coEvery { notifier.send(CourseVideoPositionChanged("", 0, 0L, false)) } returns Unit viewModel.sendTime() advanceUntilIdle() - coVerify(exactly = 1) { notifier.send(CourseVideoPositionChanged("", 0, false)) } + coVerify(exactly = 1) { notifier.send(CourseVideoPositionChanged("", 0, 0L, false)) } } @Test 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 ae34756a5..38ff2e49f 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 @@ -1,5 +1,6 @@ package org.openedx.course.presentation.videos +import android.content.Context import androidx.arch.core.executor.testing.InstantTaskExecutorRule import androidx.lifecycle.Lifecycle import androidx.lifecycle.LifecycleOwner @@ -16,7 +17,6 @@ import kotlinx.coroutines.delay import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.flow import kotlinx.coroutines.flow.flowOf -import kotlinx.coroutines.test.StandardTestDispatcher import kotlinx.coroutines.test.UnconfinedTestDispatcher import kotlinx.coroutines.test.advanceUntilIdle import kotlinx.coroutines.test.resetMain @@ -30,6 +30,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.data.model.room.VideoProgressEntity import org.openedx.core.data.storage.CorePreferences import org.openedx.core.domain.model.AssignmentProgress import org.openedx.core.domain.model.Block @@ -47,10 +48,8 @@ import org.openedx.core.module.download.DownloadHelper import org.openedx.core.presentation.CoreAnalytics import org.openedx.core.presentation.dialog.downloaddialog.DownloadDialogManager import org.openedx.core.system.connection.NetworkConnection -import org.openedx.core.system.notifier.CourseLoading import org.openedx.core.system.notifier.CourseNotifier import org.openedx.core.system.notifier.CourseStructureUpdated -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 @@ -65,15 +64,15 @@ class CourseVideoViewModelTest { @get:Rule val testInstantTaskExecutorRule: TestRule = InstantTaskExecutorRule() - private val dispatcher = StandardTestDispatcher() + private val dispatcher = UnconfinedTestDispatcher() + private val context = mockk() private val config = mockk() private val resourceManager = mockk() private val interactor = mockk() private val courseNotifier = spyk() - private val videoNotifier = spyk() - private val analytics = mockk() private val coreAnalytics = mockk() + private val courseAnalytics = mockk() private val preferencesManager = mockk() private val networkConnection = mockk() private val downloadDao = mockk() @@ -88,7 +87,8 @@ class CourseVideoViewModelTest { private val assignmentProgress = AssignmentProgress( assignmentType = "Homework", numPointsEarned = 1f, - numPointsPossible = 3f + numPointsPossible = 3f, + shortLabel = "HW1", ) private val blocks = listOf( @@ -196,7 +196,7 @@ class CourseVideoViewModelTest { every { resourceManager.getString(R.string.course_can_download_only_with_wifi) } returns cantDownload Dispatchers.setMain(dispatcher) every { config.getApiHostURL() } returns "http://localhost:8000" - every { courseNotifier.notifier } returns flowOf(CourseLoading(false)) + every { courseNotifier.notifier } returns flowOf() every { preferencesManager.isRelativeDatesEnabled } returns true every { downloadDialogManager.showPopup( @@ -219,7 +219,7 @@ class CourseVideoViewModelTest { } @Test - fun `getVideos empty list`() = runTest { + fun `getVideos empty list`() = runTest(UnconfinedTestDispatcher()) { every { config.getCourseUIConfig().isCourseDropdownNavigationEnabled } returns false coEvery { interactor.getCourseStructureForVideos(any()) @@ -228,18 +228,17 @@ class CourseVideoViewModelTest { every { preferencesManager.videoSettings } returns VideoSettings.default val viewModel = CourseVideoViewModel( "", - "", + context, config, interactor, resourceManager, networkConnection, preferencesManager, courseNotifier, - videoNotifier, - analytics, downloadDialogManager, fileUtil, courseRouter, + courseAnalytics, coreAnalytics, downloadDao, workerController, @@ -251,72 +250,77 @@ class CourseVideoViewModelTest { coVerify(exactly = 2) { interactor.getCourseStructureForVideos(any()) } - assert(viewModel.uiState.value is CourseVideosUIState.Empty) + assert(viewModel.uiState.value is CourseVideoUIState.Empty) } @Test - fun `getVideos success`() = runTest { + fun `getVideos success`() = runTest(UnconfinedTestDispatcher()) { every { config.getCourseUIConfig().isCourseDropdownNavigationEnabled } returns false coEvery { interactor.getCourseStructureForVideos(any()) } returns courseStructure - every { downloadDao.getAllDataFlow() } returns flow { emit(emptyList()) } + every { downloadDao.getAllDataFlow() } returns flow { + repeat(5) { + delay(10000) + emit(emptyList()) + } + } every { preferencesManager.videoSettings } returns VideoSettings.default - val viewModel = CourseVideoViewModel( "", - "", + context, config, interactor, resourceManager, networkConnection, preferencesManager, courseNotifier, - videoNotifier, - analytics, downloadDialogManager, fileUtil, courseRouter, + courseAnalytics, coreAnalytics, downloadDao, workerController, downloadHelper, ) - viewModel.getVideos() + val mockLifeCycleOwner: LifecycleOwner = mockk() + val lifecycleRegistry = LifecycleRegistry(mockLifeCycleOwner) + lifecycleRegistry.addObserver(viewModel) + lifecycleRegistry.handleLifecycleEvent(Lifecycle.Event.ON_START) + advanceUntilIdle() - coVerify(exactly = 2) { interactor.getCourseStructureForVideos(any()) } + coVerify(exactly = 1) { interactor.getCourseStructureForVideos(any()) } - assert(viewModel.uiState.value is CourseVideosUIState.CourseData) + assert(viewModel.uiState.value is CourseVideoUIState.CourseData) } @Test - fun `updateVideos success`() = runTest { + fun `updateVideos success`() = runTest(UnconfinedTestDispatcher()) { every { config.getCourseUIConfig().isCourseDropdownNavigationEnabled } returns false coEvery { interactor.getCourseStructureForVideos(any()) } returns courseStructure coEvery { courseNotifier.notifier } returns flow { emit(CourseStructureUpdated("")) } every { downloadDao.getAllDataFlow() } returns flow { - repeat(5) { - delay(10000) - emit(emptyList()) - } + emit(emptyList()) } every { preferencesManager.videoSettings } returns VideoSettings.default + every { networkConnection.isOnline() } returns true + coEvery { interactor.getVideoProgress(any()) } returns VideoProgressEntity("", "", 0L, 0L) val viewModel = CourseVideoViewModel( "", - "", + context, config, interactor, resourceManager, networkConnection, preferencesManager, courseNotifier, - videoNotifier, - analytics, downloadDialogManager, fileUtil, courseRouter, + courseAnalytics, coreAnalytics, downloadDao, workerController, @@ -332,11 +336,11 @@ class CourseVideoViewModelTest { coVerify(exactly = 2) { interactor.getCourseStructureForVideos(any()) } - assert(viewModel.uiState.value is CourseVideosUIState.CourseData) + assert(viewModel.uiState.value is CourseVideoUIState.CourseData) } @Test - fun `setIsUpdating success`() = runTest { + fun `setIsUpdating success`() = runTest(UnconfinedTestDispatcher()) { every { config.getCourseUIConfig().isCourseDropdownNavigationEnabled } returns false every { preferencesManager.videoSettings } returns VideoSettings.default coEvery { interactor.getCourseStructureForVideos(any()) } returns courseStructure @@ -348,20 +352,21 @@ class CourseVideoViewModelTest { fun `saveDownloadModels test`() = runTest(UnconfinedTestDispatcher()) { every { config.getCourseUIConfig().isCourseDropdownNavigationEnabled } returns false every { preferencesManager.videoSettings } returns VideoSettings.default + coEvery { interactor.getCourseStructureForVideos(any()) } returns courseStructure + every { downloadDao.getAllDataFlow() } returns flow { emit(emptyList()) } val viewModel = CourseVideoViewModel( "", - "", + context, config, interactor, resourceManager, networkConnection, preferencesManager, courseNotifier, - videoNotifier, - analytics, downloadDialogManager, fileUtil, courseRouter, + courseAnalytics, coreAnalytics, downloadDao, workerController, @@ -389,20 +394,21 @@ class CourseVideoViewModelTest { runTest(UnconfinedTestDispatcher()) { every { config.getCourseUIConfig().isCourseDropdownNavigationEnabled } returns false every { preferencesManager.videoSettings } returns VideoSettings.default + coEvery { interactor.getCourseStructureForVideos(any()) } returns courseStructure + every { downloadDao.getAllDataFlow() } returns flow { emit(emptyList()) } val viewModel = CourseVideoViewModel( "", - "", + context, config, interactor, resourceManager, networkConnection, preferencesManager, courseNotifier, - videoNotifier, - analytics, downloadDialogManager, fileUtil, courseRouter, + courseAnalytics, coreAnalytics, downloadDao, workerController, @@ -434,20 +440,21 @@ class CourseVideoViewModelTest { runTest(UnconfinedTestDispatcher()) { every { config.getCourseUIConfig().isCourseDropdownNavigationEnabled } returns false every { preferencesManager.videoSettings } returns VideoSettings.default + every { downloadDao.getAllDataFlow() } returns flow { emit(emptyList()) } + coEvery { interactor.getCourseStructureForVideos(any()) } returns courseStructure val viewModel = CourseVideoViewModel( "", - "", + context, config, interactor, resourceManager, networkConnection, preferencesManager, courseNotifier, - videoNotifier, - analytics, downloadDialogManager, fileUtil, courseRouter, + courseAnalytics, coreAnalytics, downloadDao, workerController, diff --git a/downloads/src/test/java/org/openedx/downloads/DownloadsViewModelTest.kt b/downloads/src/test/java/org/openedx/downloads/DownloadsViewModelTest.kt index 5e1622352..c57445b42 100644 --- a/downloads/src/test/java/org/openedx/downloads/DownloadsViewModelTest.kt +++ b/downloads/src/test/java/org/openedx/downloads/DownloadsViewModelTest.kt @@ -93,7 +93,8 @@ class DownloadsViewModelTest { private val assignmentProgress = AssignmentProgress( assignmentType = "Homework", numPointsEarned = 1f, - numPointsPossible = 3f + numPointsPossible = 3f, + shortLabel = "HW1", ) private val blocks = listOf( Block( From 6e45ae12b0d39ec5418d3ebdf7908a9fb838fafb Mon Sep 17 00:00:00 2001 From: PavloNetrebchuk <141041606+PavloNetrebchuk@users.noreply.github.com> Date: Fri, 5 Sep 2025 10:44:35 +0300 Subject: [PATCH 22/24] chore: dependencies upgrade (#461) --- app/build.gradle | 8 +++++--- app/proguard-rules.pro | 4 ++++ auth/build.gradle | 7 +++++-- build.gradle | 16 +++++++++------- core/build.gradle | 9 +++++---- .../main/java/org/openedx/core/ui/theme/Theme.kt | 4 ++-- course/build.gradle | 7 +++++-- .../container/CourseUnitContainerFragment.kt | 2 +- dashboard/build.gradle | 7 +++++-- discovery/build.gradle | 7 +++++-- discussion/build.gradle | 7 +++++-- downloads/build.gradle | 7 +++++-- profile/build.gradle | 7 +++++-- whatsnew/build.gradle | 8 +++++--- 14 files changed, 66 insertions(+), 34 deletions(-) diff --git a/app/build.gradle b/app/build.gradle index 651ebc5b9..f7ad7ef16 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -91,9 +91,11 @@ android { sourceCompatibility java_version targetCompatibility java_version } - kotlinOptions { - jvmTarget = java_version - freeCompilerArgs = List.of("-Xstring-concat=inline") + kotlin { + compilerOptions { + jvmTarget = jvm_target_version + freeCompilerArgs = ['-XXLanguage:+PropertyParamAnnotationDefaultTargetMode'] + } } buildFeatures { viewBinding true diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro index b1a6dc13d..0c1f551c0 100644 --- a/app/proguard-rules.pro +++ b/app/proguard-rules.pro @@ -69,3 +69,7 @@ -dontwarn org.bouncycastle.openssl.jcajce.JcaPEMKeyConverter -dontwarn com.android.billingclient.api.BillingClientStateListener -dontwarn com.android.billingclient.api.PurchasesUpdatedListener +-dontwarn com.google.crypto.tink.subtle.XChaCha20Poly1305 +-dontwarn net.jcip.annotations.GuardedBy +-dontwarn net.jcip.annotations.Immutable +-dontwarn net.jcip.annotations.ThreadSafe diff --git a/auth/build.gradle b/auth/build.gradle index a360c2d62..3bd660c15 100644 --- a/auth/build.gradle +++ b/auth/build.gradle @@ -41,8 +41,11 @@ android { sourceCompatibility java_version targetCompatibility java_version } - kotlinOptions { - jvmTarget = java_version + kotlin { + compilerOptions { + jvmTarget = jvm_target_version + freeCompilerArgs = ['-XXLanguage:+PropertyParamAnnotationDefaultTargetMode'] + } } buildFeatures { viewBinding true diff --git a/build.gradle b/build.gradle index 33a8a167e..b59516fd2 100644 --- a/build.gradle +++ b/build.gradle @@ -1,5 +1,6 @@ import io.gitlab.arturbosch.detekt.Detekt import org.edx.builder.ConfigHelper +import org.jetbrains.kotlin.gradle.dsl.JvmTarget import java.util.regex.Matcher import java.util.regex.Pattern @@ -7,21 +8,21 @@ import java.util.regex.Pattern buildscript { ext { // Plugin versions - android_gradle_plugin_version = '8.12.0' + android_gradle_plugin_version = '8.12.2' google_services_version = '4.4.3' firebase_crashlytics_version = '3.0.6' - ksp_version = '2.0.0-1.0.24' + ksp_version = '2.2.10-2.0.2' //Depends on versions in OEXFoundation - kotlin_version = '2.0.0' - room_version = '2.6.1' - detekt_version = '1.23.7' + kotlin_version = '2.2.10' + room_version = '2.7.2' + detekt_version = '1.23.8' // Library versions media3_version = "1.8.0" youtubeplayer_version = "11.1.0" firebase_version = "33.0.0" - jsoup_version = '1.21.1' + jsoup_version = '1.21.2' in_app_review = '2.0.2' extented_spans_version = "1.4.0" zip_version = '2.11.5' @@ -31,7 +32,7 @@ buildscript { play_services_ads_identifier_version = '18.2.0' install_referrer_version = '2.2' snakeyaml_version = '2.4' - openedx_foundation_version = '1.0.1' + openedx_foundation_version = '1.0.2' openedx_firebase_analytics_version = '1.0.1' braze_sdk_version = '37.0.0' @@ -84,6 +85,7 @@ ext { target_sdk_version = 36 min_sdk_version = 24 java_version = JavaVersion.VERSION_17 + jvm_target_version = JvmTarget.JVM_17 configHelper = new ConfigHelper(projectDir, getCurrentFlavor()) } diff --git a/core/build.gradle b/core/build.gradle index 76406318d..19be1f57a 100644 --- a/core/build.gradle +++ b/core/build.gradle @@ -79,11 +79,12 @@ android { sourceCompatibility java_version targetCompatibility java_version } - kotlinOptions { - jvmTarget = java_version - freeCompilerArgs = List.of("-Xstring-concat=inline") + kotlin { + compilerOptions { + jvmTarget = jvm_target_version + freeCompilerArgs = ['-XXLanguage:+PropertyParamAnnotationDefaultTargetMode'] + } } - buildFeatures { viewBinding true compose true diff --git a/core/src/main/java/org/openedx/core/ui/theme/Theme.kt b/core/src/main/java/org/openedx/core/ui/theme/Theme.kt index c4f54ac17..3ba420dcd 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 @@ -1,7 +1,7 @@ package org.openedx.core.ui.theme import androidx.compose.foundation.ExperimentalFoundationApi -import androidx.compose.foundation.LocalOverscrollConfiguration +import androidx.compose.foundation.LocalOverscrollFactory import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.material.MaterialTheme import androidx.compose.material.darkColors @@ -210,7 +210,7 @@ fun OpenEdXTheme(darkTheme: Boolean = isSystemInDarkTheme(), content: @Composabl shapes = LocalShapes.current.material, ) { CompositionLocalProvider( - LocalOverscrollConfiguration provides null, + LocalOverscrollFactory provides null, content = content ) } diff --git a/course/build.gradle b/course/build.gradle index 49dbc592f..7ae8809ed 100644 --- a/course/build.gradle +++ b/course/build.gradle @@ -28,8 +28,11 @@ android { sourceCompatibility java_version targetCompatibility java_version } - kotlinOptions { - jvmTarget = java_version + kotlin { + compilerOptions { + jvmTarget = jvm_target_version + freeCompilerArgs = ['-XXLanguage:+PropertyParamAnnotationDefaultTargetMode'] + } } buildFeatures { diff --git a/course/src/main/java/org/openedx/course/presentation/unit/container/CourseUnitContainerFragment.kt b/course/src/main/java/org/openedx/course/presentation/unit/container/CourseUnitContainerFragment.kt index c8ea5de29..1281084ce 100644 --- a/course/src/main/java/org/openedx/course/presentation/unit/container/CourseUnitContainerFragment.kt +++ b/course/src/main/java/org/openedx/course/presentation/unit/container/CourseUnitContainerFragment.kt @@ -169,7 +169,7 @@ class CourseUnitContainerFragment : Fragment(R.layout.fragment_course_unit_conta } private fun setupMediaRouteButton() { - binding.mediaRouteButton.setAlwaysVisible(true) + binding.mediaRouteButton.visibility = View.VISIBLE CastButtonFactory.setUpMediaRouteButton(requireContext(), binding.mediaRouteButton) } diff --git a/dashboard/build.gradle b/dashboard/build.gradle index a07b08958..f8272cc82 100644 --- a/dashboard/build.gradle +++ b/dashboard/build.gradle @@ -27,8 +27,11 @@ android { sourceCompatibility java_version targetCompatibility java_version } - kotlinOptions { - jvmTarget = java_version + kotlin { + compilerOptions { + jvmTarget = jvm_target_version + freeCompilerArgs = ['-XXLanguage:+PropertyParamAnnotationDefaultTargetMode'] + } } buildFeatures { diff --git a/discovery/build.gradle b/discovery/build.gradle index 3264c8b2b..efb02b6f4 100644 --- a/discovery/build.gradle +++ b/discovery/build.gradle @@ -29,8 +29,11 @@ android { sourceCompatibility java_version targetCompatibility java_version } - kotlinOptions { - jvmTarget = java_version + kotlin { + compilerOptions { + jvmTarget = jvm_target_version + freeCompilerArgs = ['-XXLanguage:+PropertyParamAnnotationDefaultTargetMode'] + } } buildFeatures { diff --git a/discussion/build.gradle b/discussion/build.gradle index cae8f215a..213571427 100644 --- a/discussion/build.gradle +++ b/discussion/build.gradle @@ -27,8 +27,11 @@ android { sourceCompatibility java_version targetCompatibility java_version } - kotlinOptions { - jvmTarget = java_version + kotlin { + compilerOptions { + jvmTarget = jvm_target_version + freeCompilerArgs = ['-XXLanguage:+PropertyParamAnnotationDefaultTargetMode'] + } } buildFeatures { diff --git a/downloads/build.gradle b/downloads/build.gradle index cd463eecf..cf1c95f77 100644 --- a/downloads/build.gradle +++ b/downloads/build.gradle @@ -29,8 +29,11 @@ android { sourceCompatibility java_version targetCompatibility java_version } - kotlinOptions { - jvmTarget = java_version + kotlin { + compilerOptions { + jvmTarget = jvm_target_version + freeCompilerArgs = ['-XXLanguage:+PropertyParamAnnotationDefaultTargetMode'] + } } buildFeatures { diff --git a/profile/build.gradle b/profile/build.gradle index 83897b149..8f4ed5783 100644 --- a/profile/build.gradle +++ b/profile/build.gradle @@ -27,8 +27,11 @@ android { sourceCompatibility java_version targetCompatibility java_version } - kotlinOptions { - jvmTarget = java_version + kotlin { + compilerOptions { + jvmTarget = jvm_target_version + freeCompilerArgs = ['-XXLanguage:+PropertyParamAnnotationDefaultTargetMode'] + } } buildFeatures { diff --git a/whatsnew/build.gradle b/whatsnew/build.gradle index cde0c7166..679843623 100644 --- a/whatsnew/build.gradle +++ b/whatsnew/build.gradle @@ -27,9 +27,11 @@ android { sourceCompatibility java_version targetCompatibility java_version } - kotlinOptions { - jvmTarget = java_version - freeCompilerArgs = List.of("-Xstring-concat=inline") + kotlin { + compilerOptions { + jvmTarget = jvm_target_version + freeCompilerArgs = ['-XXLanguage:+PropertyParamAnnotationDefaultTargetMode'] + } } buildFeatures { From f3ffa985343072a93ae73dfead031d229c67de86 Mon Sep 17 00:00:00 2001 From: PavloNetrebchuk <141041606+PavloNetrebchuk@users.noreply.github.com> Date: Tue, 7 Oct 2025 15:49:55 +0300 Subject: [PATCH 23/24] Feat: Home tab[0092] (#462) * feat: course home pager and pager indicator with navigation * feat: course completion pager tab * feat: move HomeNavigationRow to bottom bar * feat: course home pages videos card * feat: course home pages assignment card * feat: course home pages grades card * feat: added empty states * feat: CaughtUpMessage * feat: A11y * feat: detekt fixes * feat: changes according demo feedback * feat: course home analytic * feat: CourseHomeViewModelTest * fix: detekt fix and changes according PR review * feat: performance improvements * feat: changes according PR feedback * feat: changes according PR feedback --- .../org.openedx.app.room.AppDatabase/4.json | 10 +- .../main/java/org/openedx/app/AppActivity.kt | 10 +- .../main/java/org/openedx/app/AppRouter.kt | 2 +- .../openedx/app/deeplink/DeepLinkRouter.kt | 2 +- .../java/org/openedx/app/di/ScreenModule.kt | 22 + core/src/main/java/org/openedx/core/Mock.kt | 263 ++++++ .../data/model/room/VideoProgressEntity.kt | 4 +- .../core/domain/model/CourseProgress.kt | 45 +- .../org/openedx/core/extension/ListExt.kt | 18 + .../presentation/course/CourseViewMode.kt | 6 - .../core/system/notifier/CourseNotifier.kt | 1 + .../system/notifier/CourseProgressLoaded.kt | 3 + .../java/org/openedx/core/ui/PageIndicator.kt | 123 +++ .../org/openedx/core/ui/theme/AppColors.kt | 2 + .../java/org/openedx/core/ui/theme/Theme.kt | 8 +- .../org/openedx/core/utils/PreviewHelper.kt | 29 +- .../java/org/openedx/core/utils/TimeUtils.kt | 6 + core/src/main/res/values/strings.xml | 1 + .../org/openedx/core/ui/theme/Colors.kt | 6 +- .../data/repository/CourseRepository.kt | 24 +- .../domain/interactor/CourseInteractor.kt | 4 +- .../course/presentation/CourseAnalytics.kt | 28 + .../course/presentation/CourseRouter.kt | 2 +- .../assignments/CourseAssignmentViewModel.kt | 2 +- .../CourseContentAssignmentScreen.kt | 21 +- .../container/CourseContainerFragment.kt | 164 +++- .../contenttab/ContentTabEmptyState.kt | 89 +- .../presentation/dates/CourseDatesScreen.kt | 9 +- .../home/AssignmentsHomePagerCardContent.kt | 281 ++++++ .../CourseCompletionHomePagerCardContent.kt | 161 ++++ .../presentation/home/CourseHomePagerTab.kt | 8 + .../presentation/home/CourseHomeScreen.kt | 547 +++++++++++ .../presentation/home/CourseHomeUIState.kt | 31 + .../presentation/home/CourseHomeViewModel.kt | 670 ++++++++++++++ .../home/GradesHomePagerCardContent.kt | 221 +++++ .../home/VideosHomePagerCardContent.kt | 184 ++++ .../outline/CourseContentAllScreen.kt | 149 +-- .../outline/CourseContentAllViewModel.kt | 2 +- .../progress/CourseProgressScreen.kt | 336 ++++--- .../progress/CourseProgressUIState.kt | 6 +- .../progress/CourseProgressViewModel.kt | 48 +- .../section/CourseSectionFragment.kt | 2 +- .../section/CourseSectionViewModel.kt | 2 +- .../course/presentation/ui/CourseUI.kt | 196 ++-- .../container/CourseUnitContainerFragment.kt | 1 - .../container/CourseUnitContainerViewModel.kt | 1 - .../unit/container/CourseViewMode.kt | 6 + .../unit/video/EncodedVideoUnitViewModel.kt | 10 +- .../videos/CourseContentVideoScreen.kt | 7 +- .../presentation/videos/CourseVideoUIState.kt | 2 +- .../videos/CourseVideoViewModel.kt | 42 +- course/src/main/res/values/strings.xml | 35 +- .../home/CourseHomeViewModelTest.kt | 863 ++++++++++++++++++ .../section/CourseSectionViewModelTest.kt | 2 +- .../CourseUnitContainerViewModelTest.kt | 1 - .../whatsnew/presentation/ui/WhatsNewUI.kt | 109 --- .../presentation/whatsnew/WhatsNewFragment.kt | 2 +- 57 files changed, 4208 insertions(+), 621 deletions(-) create mode 100644 core/src/main/java/org/openedx/core/Mock.kt delete mode 100644 core/src/main/java/org/openedx/core/presentation/course/CourseViewMode.kt create mode 100644 core/src/main/java/org/openedx/core/system/notifier/CourseProgressLoaded.kt create mode 100644 core/src/main/java/org/openedx/core/ui/PageIndicator.kt create mode 100644 course/src/main/java/org/openedx/course/presentation/home/AssignmentsHomePagerCardContent.kt create mode 100644 course/src/main/java/org/openedx/course/presentation/home/CourseCompletionHomePagerCardContent.kt create mode 100644 course/src/main/java/org/openedx/course/presentation/home/CourseHomePagerTab.kt create mode 100644 course/src/main/java/org/openedx/course/presentation/home/CourseHomeScreen.kt create mode 100644 course/src/main/java/org/openedx/course/presentation/home/CourseHomeUIState.kt create mode 100644 course/src/main/java/org/openedx/course/presentation/home/CourseHomeViewModel.kt create mode 100644 course/src/main/java/org/openedx/course/presentation/home/GradesHomePagerCardContent.kt create mode 100644 course/src/main/java/org/openedx/course/presentation/home/VideosHomePagerCardContent.kt create mode 100644 course/src/main/java/org/openedx/course/presentation/unit/container/CourseViewMode.kt create mode 100644 course/src/test/java/org/openedx/course/presentation/home/CourseHomeViewModelTest.kt diff --git a/app/schemas/org.openedx.app.room.AppDatabase/4.json b/app/schemas/org.openedx.app.room.AppDatabase/4.json index 0f1e1c17b..0bf47775d 100644 --- a/app/schemas/org.openedx.app.room.AppDatabase/4.json +++ b/app/schemas/org.openedx.app.room.AppDatabase/4.json @@ -2,7 +2,7 @@ "formatVersion": 1, "database": { "version": 4, - "identityHash": "488bd2b78e977fef626afb28014c80f2", + "identityHash": "7ea446decde04c9c16700cb3981703c2", "entities": [ { "tableName": "course_discovery_table", @@ -1008,7 +1008,7 @@ }, { "tableName": "video_progress_table", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`block_id` TEXT NOT NULL, `video_url` TEXT NOT NULL, `video_time` INTEGER NOT NULL, `duration` INTEGER NOT NULL, PRIMARY KEY(`block_id`))", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`block_id` TEXT NOT NULL, `video_url` TEXT NOT NULL, `video_time` INTEGER, `duration` INTEGER, PRIMARY KEY(`block_id`))", "fields": [ { "fieldPath": "blockId", @@ -1026,13 +1026,13 @@ "fieldPath": "videoTime", "columnName": "video_time", "affinity": "INTEGER", - "notNull": true + "notNull": false }, { "fieldPath": "duration", "columnName": "duration", "affinity": "INTEGER", - "notNull": true + "notNull": false } ], "primaryKey": { @@ -1230,7 +1230,7 @@ "views": [], "setupQueries": [ "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", - "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '488bd2b78e977fef626afb28014c80f2')" + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '7ea446decde04c9c16700cb3981703c2')" ] } } \ No newline at end of file diff --git a/app/src/main/java/org/openedx/app/AppActivity.kt b/app/src/main/java/org/openedx/app/AppActivity.kt index 11b913d50..b904bf6a1 100644 --- a/app/src/main/java/org/openedx/app/AppActivity.kt +++ b/app/src/main/java/org/openedx/app/AppActivity.kt @@ -2,7 +2,9 @@ package org.openedx.app import android.content.Intent import android.content.res.Configuration +import android.graphics.Color import android.net.Uri +import android.os.Build import android.os.Bundle import android.view.View import android.view.WindowManager @@ -158,8 +160,12 @@ class AppActivity : AppCompatActivity(), InsetHolder, WindowSizeHolder { WindowCompat.setDecorFitsSystemWindows(this, false) val insetsController = WindowInsetsControllerCompat(this, binding.root) insetsController.isAppearanceLightStatusBars = !isUsingNightModeResources() - insetsController.systemBarsBehavior = - WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.VANILLA_ICE_CREAM) { + insetsController.systemBarsBehavior = + WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE + } else { + window.statusBarColor = Color.TRANSPARENT + } } } diff --git a/app/src/main/java/org/openedx/app/AppRouter.kt b/app/src/main/java/org/openedx/app/AppRouter.kt index cfe1ecc44..4678344ee 100644 --- a/app/src/main/java/org/openedx/app/AppRouter.kt +++ b/app/src/main/java/org/openedx/app/AppRouter.kt @@ -11,7 +11,6 @@ import org.openedx.auth.presentation.signin.SignInFragment import org.openedx.auth.presentation.signup.SignUpFragment import org.openedx.core.CalendarRouter import org.openedx.core.FragmentViewType -import org.openedx.core.presentation.course.CourseViewMode import org.openedx.core.presentation.global.appupgrade.AppUpgradeRouter import org.openedx.core.presentation.global.appupgrade.UpgradeRequiredFragment import org.openedx.core.presentation.global.webview.WebContentFragment @@ -24,6 +23,7 @@ import org.openedx.course.presentation.handouts.HandoutsType import org.openedx.course.presentation.handouts.HandoutsWebViewFragment import org.openedx.course.presentation.section.CourseSectionFragment import org.openedx.course.presentation.unit.container.CourseUnitContainerFragment +import org.openedx.course.presentation.unit.container.CourseViewMode import org.openedx.course.presentation.unit.video.VideoFullScreenFragment import org.openedx.course.presentation.unit.video.YoutubeVideoFullScreenFragment import org.openedx.course.settings.download.DownloadQueueFragment diff --git a/app/src/main/java/org/openedx/app/deeplink/DeepLinkRouter.kt b/app/src/main/java/org/openedx/app/deeplink/DeepLinkRouter.kt index 6061eb6b1..2192a6b89 100644 --- a/app/src/main/java/org/openedx/app/deeplink/DeepLinkRouter.kt +++ b/app/src/main/java/org/openedx/app/deeplink/DeepLinkRouter.kt @@ -11,9 +11,9 @@ import org.openedx.auth.presentation.signin.SignInFragment import org.openedx.core.FragmentViewType import org.openedx.core.config.Config import org.openedx.core.data.storage.CorePreferences -import org.openedx.core.presentation.course.CourseViewMode import org.openedx.course.domain.interactor.CourseInteractor import org.openedx.course.presentation.handouts.HandoutsType +import org.openedx.course.presentation.unit.container.CourseViewMode import org.openedx.discovery.domain.interactor.DiscoveryInteractor import org.openedx.discovery.domain.model.Course import org.openedx.discovery.presentation.catalog.WebViewLink diff --git a/app/src/main/java/org/openedx/app/di/ScreenModule.kt b/app/src/main/java/org/openedx/app/di/ScreenModule.kt index 5d8f1eb5a..07caf8037 100644 --- a/app/src/main/java/org/openedx/app/di/ScreenModule.kt +++ b/app/src/main/java/org/openedx/app/di/ScreenModule.kt @@ -23,6 +23,7 @@ import org.openedx.course.presentation.container.CourseContainerViewModel import org.openedx.course.presentation.contenttab.ContentTabViewModel import org.openedx.course.presentation.dates.CourseDatesViewModel import org.openedx.course.presentation.handouts.HandoutsViewModel +import org.openedx.course.presentation.home.CourseHomeViewModel import org.openedx.course.presentation.offline.CourseOfflineViewModel import org.openedx.course.presentation.outline.CourseContentAllViewModel import org.openedx.course.presentation.progress.CourseProgressViewModel @@ -309,6 +310,27 @@ val screenModule = module { get(), ) } + viewModel { (courseId: String, courseTitle: String) -> + CourseHomeViewModel( + courseId, + courseTitle, + get(), + get(), + get(), + get(), + get(), + get(), + get(), + get(), + get(), + get(), + get(), + get(), + get(), + get(), + get() + ) + } viewModel { (courseId: String) -> CourseSectionViewModel( courseId, diff --git a/core/src/main/java/org/openedx/core/Mock.kt b/core/src/main/java/org/openedx/core/Mock.kt new file mode 100644 index 000000000..445fc7a05 --- /dev/null +++ b/core/src/main/java/org/openedx/core/Mock.kt @@ -0,0 +1,263 @@ +package org.openedx.core + +import org.openedx.core.data.model.room.VideoProgressEntity +import org.openedx.core.domain.model.AssignmentProgress +import org.openedx.core.domain.model.Block +import org.openedx.core.domain.model.BlockCounts +import org.openedx.core.domain.model.CourseComponentStatus +import org.openedx.core.domain.model.CourseDatesBannerInfo +import org.openedx.core.domain.model.CourseDatesResult +import org.openedx.core.domain.model.CourseProgress +import org.openedx.core.domain.model.CourseStructure +import org.openedx.core.domain.model.CoursewareAccess +import org.openedx.core.domain.model.EncodedVideos +import org.openedx.core.domain.model.OfflineDownload +import org.openedx.core.domain.model.Progress +import org.openedx.core.domain.model.ResetCourseDates +import org.openedx.core.domain.model.StudentViewData +import org.openedx.core.domain.model.VideoInfo +import org.openedx.core.module.db.DownloadModel +import org.openedx.core.module.db.DownloadedState +import org.openedx.core.module.db.FileType +import java.util.Date + +object Mock { + private val mockAssignmentProgress = AssignmentProgress( + assignmentType = "Home", + numPointsEarned = 1f, + numPointsPossible = 3f, + shortLabel = "HM1" + ) + val mockChapterBlock = Block( + id = "id", + blockId = "blockId", + lmsWebUrl = "lmsWebUrl", + legacyWebUrl = "legacyWebUrl", + studentViewUrl = "studentViewUrl", + type = BlockType.CHAPTER, + displayName = "Chapter", + graded = false, + studentViewData = null, + studentViewMultiDevice = false, + blockCounts = BlockCounts(1), + descendants = emptyList(), + descendantsType = BlockType.CHAPTER, + completion = 0.0, + containsGatedContent = false, + assignmentProgress = mockAssignmentProgress, + due = Date(), + offlineDownload = null + ) + private val mockSequentialBlock = Block( + id = "id", + blockId = "blockId", + lmsWebUrl = "lmsWebUrl", + legacyWebUrl = "legacyWebUrl", + studentViewUrl = "studentViewUrl", + type = BlockType.SEQUENTIAL, + displayName = "Sequential", + graded = false, + studentViewData = null, + studentViewMultiDevice = false, + blockCounts = BlockCounts(1), + descendants = emptyList(), + descendantsType = BlockType.CHAPTER, + completion = 0.0, + containsGatedContent = false, + assignmentProgress = mockAssignmentProgress, + due = Date(), + offlineDownload = OfflineDownload("fileUrl", "", 1), + ) + + val mockCourseStructure = CourseStructure( + root = "", + blockData = listOf(mockSequentialBlock, mockSequentialBlock), + id = "id", + name = "Course name", + number = "", + org = "Org", + start = Date(), + startDisplay = "", + startType = "", + end = Date(), + coursewareAccess = CoursewareAccess( + true, + "", + "", + "", + "", + "" + ), + media = null, + certificate = null, + isSelfPaced = false, + progress = Progress(1, 3), + ) + + val mockCourseComponentStatus = CourseComponentStatus( + lastVisitedBlockId = "video1" + ) + + val mockCourseDatesBannerInfo = CourseDatesBannerInfo( + missedDeadlines = false, + missedGatedContent = false, + contentTypeGatingEnabled = false, + verifiedUpgradeLink = "", + hasEnded = false + ) + + val mockCourseDatesResult = CourseDatesResult( + datesSection = linkedMapOf(), + courseBanner = mockCourseDatesBannerInfo + ) + + val mockCourseProgress = CourseProgress( + verifiedMode = "audit", + accessExpiration = "", + certificateData = null, + completionSummary = null, + courseGrade = null, + creditCourseRequirements = "", + end = "", + enrollmentMode = "audit", + gradingPolicy = null, + hasScheduledContent = false, + sectionScores = emptyList(), + studioUrl = "", + username = "testuser", + userHasPassingGrade = false, + verificationData = null, + disableProgressGraph = false + ) + + val mockVideoProgress = VideoProgressEntity( + blockId = "video1", + videoUrl = "test-video-url", + videoTime = 1000L, + duration = 5000L + ) + + val mockResetCourseDates = ResetCourseDates( + message = "Dates reset successfully", + body = "Your course dates have been reset", + header = "Success", + link = "", + linkText = "" + ) + + val mockDownloadModel = DownloadModel( + id = "video1", + title = "Video 1", + courseId = "test-course-id", + size = 1000L, + path = "/test/path/video1", + url = "test-url", + type = FileType.VIDEO, + downloadedState = DownloadedState.NOT_DOWNLOADED, + lastModified = null + ) + + val mockVideoBlock = Block( + id = "video1", + blockId = "video1", + lmsWebUrl = "lmsWebUrl", + legacyWebUrl = "legacyWebUrl", + studentViewUrl = "studentViewUrl", + type = BlockType.VIDEO, + displayName = "Video 1", + graded = false, + studentViewData = StudentViewData( + onlyOnWeb = false, + duration = "", + transcripts = null, + encodedVideos = EncodedVideos( + youtube = null, + hls = null, + fallback = null, + desktopMp4 = null, + mobileHigh = null, + mobileLow = VideoInfo( + url = "test-url", + fileSize = 1000L + ) + ), + topicId = "" + ), + studentViewMultiDevice = false, + blockCounts = BlockCounts(0), + descendants = emptyList(), + descendantsType = BlockType.VIDEO, + completion = 0.0, + containsGatedContent = false, + assignmentProgress = null, + due = null, + offlineDownload = null, + ) + + val mockSequentialBlockForDownload = Block( + id = "sequential1", + blockId = "sequential1", + lmsWebUrl = "lmsWebUrl", + legacyWebUrl = "legacyWebUrl", + studentViewUrl = "studentViewUrl", + type = BlockType.SEQUENTIAL, + displayName = "Sequential 1", + graded = false, + studentViewData = null, + studentViewMultiDevice = false, + blockCounts = BlockCounts(0), + descendants = listOf("vertical1"), + descendantsType = BlockType.VERTICAL, + completion = 0.0, + containsGatedContent = false, + assignmentProgress = null, + due = null, + offlineDownload = null, + ) + + val mockVerticalBlock = Block( + id = "vertical1", + blockId = "vertical1", + lmsWebUrl = "lmsWebUrl", + legacyWebUrl = "legacyWebUrl", + studentViewUrl = "studentViewUrl", + type = BlockType.VERTICAL, + displayName = "Vertical 1", + graded = false, + studentViewData = null, + studentViewMultiDevice = false, + blockCounts = BlockCounts(0), + descendants = listOf("video1"), + descendantsType = BlockType.VIDEO, + completion = 0.0, + containsGatedContent = false, + assignmentProgress = null, + due = null, + offlineDownload = null, + ) + + val mockCourseStructureForDownload = CourseStructure( + root = "sequential1", + blockData = listOf(mockSequentialBlockForDownload, mockVerticalBlock, mockVideoBlock), + id = "test-course-id", + name = "Test Course", + number = "CS101", + org = "TestOrg", + start = Date(), + startDisplay = "2024-01-01", + startType = "timestamped", + end = Date(), + coursewareAccess = CoursewareAccess( + true, + "", + "", + "", + "", + "" + ), + media = null, + certificate = null, + isSelfPaced = false, + progress = null + ) +} diff --git a/core/src/main/java/org/openedx/core/data/model/room/VideoProgressEntity.kt b/core/src/main/java/org/openedx/core/data/model/room/VideoProgressEntity.kt index fbe2866e7..2ee58d802 100644 --- a/core/src/main/java/org/openedx/core/data/model/room/VideoProgressEntity.kt +++ b/core/src/main/java/org/openedx/core/data/model/room/VideoProgressEntity.kt @@ -12,7 +12,7 @@ data class VideoProgressEntity( @ColumnInfo("video_url") val videoUrl: String, @ColumnInfo("video_time") - val videoTime: Long, + val videoTime: Long?, @ColumnInfo("duration") - val duration: Long, + val duration: Long?, ) diff --git a/core/src/main/java/org/openedx/core/domain/model/CourseProgress.kt b/core/src/main/java/org/openedx/core/domain/model/CourseProgress.kt index 537959ece..77ae5f65a 100644 --- a/core/src/main/java/org/openedx/core/domain/model/CourseProgress.kt +++ b/core/src/main/java/org/openedx/core/domain/model/CourseProgress.kt @@ -28,32 +28,16 @@ data class CourseProgress( val requiredGrade = gradingPolicy?.gradeRange?.values?.firstOrNull() ?: 0f val requiredGradePercent = (requiredGrade * 100f).toInt() - fun getEarnedAssignmentProblems( - policy: GradingPolicy.AssignmentPolicy - ) = sectionScores - .flatMap { section -> - section.subsections.filter { it.assignmentType == policy.type } - }.sumOf { subsection -> - subsection.problemScores.sumOf { it.earned } - } - - fun getPossibleAssignmentProblems( - policy: GradingPolicy.AssignmentPolicy - ) = sectionScores - .flatMap { section -> - section.subsections.filter { it.assignmentType == policy.type } - }.sumOf { subsection -> - subsection.problemScores.sumOf { it.possible } - } - fun getAssignmentGradedPercent(type: String): Float { - val assignmentSections = sectionScores - .flatMap { it.subsections } - .filter { it.assignmentType == type } + val assignmentSections = getAssignmentSections(type) if (assignmentSections.isEmpty()) return 0f return assignmentSections.sumOf { it.percentGraded }.toFloat() / assignmentSections.size } + fun getAssignmentSections(type: String) = sectionScores + .flatMap { it.subsections } + .filter { it.assignmentType == type } + fun getAssignmentWeightedGradedPercent(assignmentPolicy: GradingPolicy.AssignmentPolicy): Float { return (assignmentPolicy.weight * getAssignmentGradedPercent(assignmentPolicy.type) * 100f).toFloat() } @@ -68,6 +52,25 @@ data class CourseProgress( return if (notCompletedPercent < 0.0) 0f else notCompletedPercent.toFloat() } + fun getNotEmptyGradingPolicies() = gradingPolicy?.assignmentPolicies?.mapNotNull { + if (getAssignmentSections(it.type).isNotEmpty()) { + it + } else { + null + } + } + + fun getCompletedAssignmentCount( + policy: GradingPolicy.AssignmentPolicy, + courseStructure: CourseStructure? = null + ): Int { + val assignments = getAssignmentSections(policy.type) + return courseStructure?.blockData + ?.filter { it.id in assignments.map { assignment -> assignment.blockKey } } + ?.filter { it.isCompleted() } + ?.size ?: 0 + } + data class CertificateData( val certStatus: String, val certWebViewUrl: String, diff --git a/core/src/main/java/org/openedx/core/extension/ListExt.kt b/core/src/main/java/org/openedx/core/extension/ListExt.kt index 6a802755f..f5cc21279 100644 --- a/core/src/main/java/org/openedx/core/extension/ListExt.kt +++ b/core/src/main/java/org/openedx/core/extension/ListExt.kt @@ -14,3 +14,21 @@ fun List.getSequentialBlocks(): List { fun List.getChapterBlocks(): List { return this.filter { it.type == BlockType.CHAPTER } } + +fun List.getUnitChapter(blockId: String): Block? { + val verticalBlock = this.firstOrNull { + it.type == BlockType.VERTICAL && it.descendants.contains(blockId) + } + + val sequentialBlock = verticalBlock?.let { vertical -> + this.firstOrNull { + it.type == BlockType.SEQUENTIAL && it.descendants.contains(vertical.id) + } + } + + return sequentialBlock?.let { sequential -> + this.firstOrNull { + it.type == BlockType.CHAPTER && it.descendants.contains(sequential.id) + } + } +} diff --git a/core/src/main/java/org/openedx/core/presentation/course/CourseViewMode.kt b/core/src/main/java/org/openedx/core/presentation/course/CourseViewMode.kt deleted file mode 100644 index 8a73475ed..000000000 --- a/core/src/main/java/org/openedx/core/presentation/course/CourseViewMode.kt +++ /dev/null @@ -1,6 +0,0 @@ -package org.openedx.core.presentation.course - -enum class CourseViewMode { - FULL, - VIDEOS -} diff --git a/core/src/main/java/org/openedx/core/system/notifier/CourseNotifier.kt b/core/src/main/java/org/openedx/core/system/notifier/CourseNotifier.kt index d3dac7d42..6272ded50 100644 --- a/core/src/main/java/org/openedx/core/system/notifier/CourseNotifier.kt +++ b/core/src/main/java/org/openedx/core/system/notifier/CourseNotifier.kt @@ -23,4 +23,5 @@ class CourseNotifier { suspend fun send(event: RefreshDates) = channel.emit(event) suspend fun send(event: RefreshDiscussions) = channel.emit(event) suspend fun send(event: RefreshProgress) = channel.emit(event) + suspend fun send(event: CourseProgressLoaded) = channel.emit(event) } diff --git a/core/src/main/java/org/openedx/core/system/notifier/CourseProgressLoaded.kt b/core/src/main/java/org/openedx/core/system/notifier/CourseProgressLoaded.kt new file mode 100644 index 000000000..482d9271e --- /dev/null +++ b/core/src/main/java/org/openedx/core/system/notifier/CourseProgressLoaded.kt @@ -0,0 +1,3 @@ +package org.openedx.core.system.notifier + +object CourseProgressLoaded : CourseEvent diff --git a/core/src/main/java/org/openedx/core/ui/PageIndicator.kt b/core/src/main/java/org/openedx/core/ui/PageIndicator.kt new file mode 100644 index 000000000..8e9f4f40b --- /dev/null +++ b/core/src/main/java/org/openedx/core/ui/PageIndicator.kt @@ -0,0 +1,123 @@ +package org.openedx.core.ui + +import androidx.compose.animation.animateColorAsState +import androidx.compose.animation.core.animateDpAsState +import androidx.compose.animation.core.tween +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.size +import androidx.compose.material.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.CornerRadius +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import org.openedx.core.ui.theme.OpenEdXTheme +import org.openedx.core.ui.theme.appColors + +@Composable +fun PageIndicator( + numberOfPages: Int, + modifier: Modifier = Modifier, + selectedPage: Int = 0, + selectedColor: Color = MaterialTheme.appColors.info, + previousUnselectedColor: Color = MaterialTheme.appColors.cardViewBorder, + nextUnselectedColor: Color = MaterialTheme.appColors.textFieldBorder, + defaultRadius: Dp = 20.dp, + selectedLength: Dp = 60.dp, + space: Dp = 30.dp, + animationDurationInMillis: Int = 300, +) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(space), + modifier = modifier, + ) { + for (i in 0 until numberOfPages) { + val isSelected = i == selectedPage + val unselectedColor = + if (i < selectedPage) previousUnselectedColor else nextUnselectedColor + PageIndicatorView( + isSelected = isSelected, + selectedColor = selectedColor, + defaultColor = unselectedColor, + defaultRadius = defaultRadius, + selectedLength = selectedLength, + animationDurationInMillis = animationDurationInMillis, + ) + } + } +} + +@Composable +fun PageIndicatorView( + isSelected: Boolean, + selectedColor: Color, + defaultColor: Color, + defaultRadius: Dp, + selectedLength: Dp, + animationDurationInMillis: Int, + modifier: Modifier = Modifier, +) { + val color: Color by animateColorAsState( + targetValue = if (isSelected) { + selectedColor + } else { + defaultColor + }, + animationSpec = tween( + durationMillis = animationDurationInMillis, + ), + label = "" + ) + val width: Dp by animateDpAsState( + targetValue = if (isSelected) { + selectedLength + } else { + defaultRadius + }, + animationSpec = tween( + durationMillis = animationDurationInMillis, + ), + label = "" + ) + + Canvas( + modifier = modifier + .size( + width = width, + height = defaultRadius, + ), + ) { + drawRoundRect( + color = color, + topLeft = Offset.Zero, + size = Size( + width = width.toPx(), + height = defaultRadius.toPx(), + ), + cornerRadius = CornerRadius( + x = defaultRadius.toPx(), + y = defaultRadius.toPx(), + ), + ) + } +} + +@Preview +@Composable +private fun PageIndicatorViewPreview() { + OpenEdXTheme { + PageIndicator( + numberOfPages = 4, + selectedPage = 2 + ) + } +} diff --git a/core/src/main/java/org/openedx/core/ui/theme/AppColors.kt b/core/src/main/java/org/openedx/core/ui/theme/AppColors.kt index 143bfabf7..bf20366d9 100644 --- a/core/src/main/java/org/openedx/core/ui/theme/AppColors.kt +++ b/core/src/main/java/org/openedx/core/ui/theme/AppColors.kt @@ -80,6 +80,8 @@ data class AppColors( val progressBarColor: Color, val progressBarBackgroundColor: Color, val gradeProgressBarBorder: Color, + val gradeProgressBarBackground: Color, + val assignmentCardBorder: Color, ) { val primary: Color get() = material.primary val primaryVariant: Color get() = material.primaryVariant diff --git a/core/src/main/java/org/openedx/core/ui/theme/Theme.kt b/core/src/main/java/org/openedx/core/ui/theme/Theme.kt index 3ba420dcd..9b42c90ac 100644 --- a/core/src/main/java/org/openedx/core/ui/theme/Theme.kt +++ b/core/src/main/java/org/openedx/core/ui/theme/Theme.kt @@ -97,7 +97,9 @@ private val DarkColorPalette = AppColors( progressBarColor = dark_progress_bar_color, progressBarBackgroundColor = dark_progress_bar_background_color, - gradeProgressBarBorder = dark_grade_progress_bar_color + gradeProgressBarBorder = dark_grade_progress_bar_color, + gradeProgressBarBackground = dark_grade_progress_bar_background, + assignmentCardBorder = dark_assignment_card_border, ) private val LightColorPalette = AppColors( @@ -187,7 +189,9 @@ private val LightColorPalette = AppColors( progressBarColor = light_progress_bar_color, progressBarBackgroundColor = light_progress_bar_background_color, - gradeProgressBarBorder = light_grade_progress_bar_color + gradeProgressBarBorder = light_grade_progress_bar_color, + gradeProgressBarBackground = light_grade_progress_bar_background, + assignmentCardBorder = light_assignment_card_border, ) val MaterialTheme.appColors: AppColors diff --git a/core/src/main/java/org/openedx/core/utils/PreviewHelper.kt b/core/src/main/java/org/openedx/core/utils/PreviewHelper.kt index 03227050b..dd3d65fdf 100644 --- a/core/src/main/java/org/openedx/core/utils/PreviewHelper.kt +++ b/core/src/main/java/org/openedx/core/utils/PreviewHelper.kt @@ -7,6 +7,9 @@ import android.media.MediaMetadataRetriever import java.io.File import java.io.FileOutputStream import java.security.MessageDigest +import java.util.concurrent.Executors +import java.util.concurrent.TimeUnit +import java.util.concurrent.TimeoutException data class VideoPreview( val link: String? = null, @@ -25,6 +28,9 @@ data class VideoPreview( object PreviewHelper { + private const val TIMEOUT_MS = 5000L // 5 seconds + private val executor = Executors.newSingleThreadExecutor() + fun getYouTubeThumbnailUrl(url: String): String { val videoId = extractYouTubeVideoId(url) return "https://img.youtube.com/vi/$videoId/0.jpg" @@ -49,15 +55,34 @@ object PreviewHelper { try { BitmapFactory.decodeFile(cacheFile.absolutePath) } catch (_: Exception) { - extractBitmapFromVideo(videoUrl, context) + // If cache file is corrupted, try to extract from video with timeout + extractBitmapFromVideoWithTimeout(videoUrl, context) } } else { - extractBitmapFromVideo(videoUrl, context) + // Extract from video with timeout + extractBitmapFromVideoWithTimeout(videoUrl, context) } } return result } + private fun extractBitmapFromVideoWithTimeout(videoUrl: String, context: Context): Bitmap? { + return try { + val future = executor.submit { + extractBitmapFromVideo(videoUrl, context) + } + future.get(TIMEOUT_MS, TimeUnit.MILLISECONDS) + } catch (e: TimeoutException) { + // Server didn't respond within timeout, return null immediately + e.printStackTrace() + null + } catch (e: Exception) { + // Any other exception, return null immediately + e.printStackTrace() + null + } + } + private fun extractBitmapFromVideo(videoUrl: String, context: Context): Bitmap? { val retriever = MediaMetadataRetriever() try { diff --git a/core/src/main/java/org/openedx/core/utils/TimeUtils.kt b/core/src/main/java/org/openedx/core/utils/TimeUtils.kt index 572d4bc5c..cba6a5e8e 100644 --- a/core/src/main/java/org/openedx/core/utils/TimeUtils.kt +++ b/core/src/main/java/org/openedx/core/utils/TimeUtils.kt @@ -20,6 +20,7 @@ object TimeUtils { private const val FORMAT_ISO_8601 = "yyyy-MM-dd'T'HH:mm:ss'Z'" private const val FORMAT_ISO_8601_WITH_TIME_ZONE = "yyyy-MM-dd'T'HH:mm:ssXXX" + private const val FORMAT_MONTH_DAY = "MMM dd" private const val SEVEN_DAYS_IN_MILLIS = 604800000L fun formatToString(context: Context, date: Date, useRelativeDates: Boolean): String { @@ -97,6 +98,11 @@ object TimeUtils { } } + fun formatToMonthDay(date: Date): String { + val sdf = SimpleDateFormat(FORMAT_MONTH_DAY, Locale.getDefault()) + return sdf.format(date) + } + fun getCurrentTime(): Long { return Calendar.getInstance().timeInMillis } diff --git a/core/src/main/res/values/strings.xml b/core/src/main/res/values/strings.xml index 405751cf8..f4fabd553 100644 --- a/core/src/main/res/values/strings.xml +++ b/core/src/main/res/values/strings.xml @@ -176,6 +176,7 @@ Not Synced Syncing to calendar… Next + Previous Downloads (Untitled) diff --git a/core/src/openedx/org/openedx/core/ui/theme/Colors.kt b/core/src/openedx/org/openedx/core/ui/theme/Colors.kt index df4f6c357..089acc04f 100644 --- a/core/src/openedx/org/openedx/core/ui/theme/Colors.kt +++ b/core/src/openedx/org/openedx/core/ui/theme/Colors.kt @@ -48,7 +48,7 @@ val light_divider = Color(0xFFCCD4E0) val light_certificate_foreground = Color(0xD94BD191) val light_bottom_sheet_toggle = Color(0xFF4E5A70) val light_warning = Color(0xFFFFC94D) -val light_info = Color(0xFF42AAFF) +val light_info = Color(0xFF3A9AE9) val light_rate_stars = Color(0xFFFFC94D) val light_inactive_button_background = Color(0xFFCCD4E0) val light_success_green = Color(0xFF198571) @@ -75,6 +75,8 @@ val light_settings_title_content = Color.White val light_progress_bar_color = light_success_green val light_progress_bar_background_color = Color(0xFFCCD4E0) val light_grade_progress_bar_color = Color.Black +val light_grade_progress_bar_background = Color(0xFFCCD4E0) +val light_assignment_card_border = Color(0xFFCCD4E0) val dark_primary = Color(0xFF3F68F8) val dark_primary_variant = Color(0xFF3700B3) @@ -149,3 +151,5 @@ val dark_settings_title_content = Color.White val dark_progress_bar_color = dark_success_green val dark_progress_bar_background_color = Color(0xFF8E9BAE) val dark_grade_progress_bar_color = Color.Transparent +val dark_grade_progress_bar_background = Color(0xFF8E9BAE) +val dark_assignment_card_border = Color(0xFF8E9BAE) diff --git a/course/src/main/java/org/openedx/course/data/repository/CourseRepository.kt b/course/src/main/java/org/openedx/course/data/repository/CourseRepository.kt index 2e460bfa6..a6db3df5a 100644 --- a/course/src/main/java/org/openedx/course/data/repository/CourseRepository.kt +++ b/course/src/main/java/org/openedx/course/data/repository/CourseRepository.kt @@ -6,6 +6,7 @@ import okhttp3.MultipartBody import org.openedx.core.ApiConstants import org.openedx.core.data.api.CourseApi import org.openedx.core.data.model.BlocksCompletionBody +import org.openedx.core.data.model.room.CourseProgressEntity import org.openedx.core.data.model.room.OfflineXBlockProgress import org.openedx.core.data.model.room.VideoProgressEntity import org.openedx.core.data.model.room.XBlockProgressData @@ -253,19 +254,26 @@ class CourseRepository( suspend fun getVideoProgress(blockId: String): VideoProgressEntity { return courseDao.getVideoProgressByBlockId(blockId) - ?: VideoProgressEntity(blockId, "", 0L, 0L) + ?: VideoProgressEntity(blockId, "", null, null) } - fun getCourseProgress(courseId: String, isRefresh: Boolean): Flow = + fun getCourseProgress( + courseId: String, + isRefresh: Boolean, + getOnlyCacheIfExist: Boolean // If true, only returns cached data if available, otherwise fetches from network + ): Flow = channelFlowWithAwait { + var courseProgress: CourseProgressEntity? = null if (!isRefresh) { - val cached = courseDao.getCourseProgressById(courseId) - if (cached != null) { - trySend(cached.mapToDomain()) + courseProgress = courseDao.getCourseProgressById(courseId) + if (courseProgress != null) { + trySend(courseProgress.mapToDomain()) } } - val response = api.getCourseProgress(courseId) - courseDao.insertCourseProgressEntity(response.mapToRoomEntity(courseId)) - trySend(response.mapToDomain()) + if (networkConnection.isOnline() && (!getOnlyCacheIfExist || courseProgress == null)) { + val response = api.getCourseProgress(courseId) + courseDao.insertCourseProgressEntity(response.mapToRoomEntity(courseId)) + trySend(response.mapToDomain()) + } } } diff --git a/course/src/main/java/org/openedx/course/domain/interactor/CourseInteractor.kt b/course/src/main/java/org/openedx/course/domain/interactor/CourseInteractor.kt index 7da1623d7..86788f34c 100644 --- a/course/src/main/java/org/openedx/course/domain/interactor/CourseInteractor.kt +++ b/course/src/main/java/org/openedx/course/domain/interactor/CourseInteractor.kt @@ -120,8 +120,8 @@ class CourseInteractor( suspend fun submitOfflineXBlockProgress(blockId: String, courseId: String) = repository.submitOfflineXBlockProgress(blockId, courseId) - fun getCourseProgress(courseId: String, isRefresh: Boolean) = - repository.getCourseProgress(courseId, isRefresh) + fun getCourseProgress(courseId: String, isRefresh: Boolean, getOnlyCacheIfExist: Boolean) = + repository.getCourseProgress(courseId, isRefresh, getOnlyCacheIfExist) suspend fun getVideoProgress(blockId: String) = repository.getVideoProgress(blockId) } diff --git a/course/src/main/java/org/openedx/course/presentation/CourseAnalytics.kt b/course/src/main/java/org/openedx/course/presentation/CourseAnalytics.kt index 99ff6d2e1..68468c654 100644 --- a/course/src/main/java/org/openedx/course/presentation/CourseAnalytics.kt +++ b/course/src/main/java/org/openedx/course/presentation/CourseAnalytics.kt @@ -178,6 +178,34 @@ enum class CourseAnalyticsEvent(val eventName: String, val biValue: String) { "Course:Assignment Tab.Assignment Clicked", "edx.bi.app.course.assignment_tab.assignment.clicked" ), + COURSE_HOME_SECTION_SUBSECTION_CLICK( + "Course Home.Section/Subsection Click", + "edx.bi.app.course.home.section_subsection.clicked" + ), + COURSE_HOME_VIEW_ALL_CONTENT( + "Course Home.View All Content", + "edx.bi.app.course.home.view_all_content.clicked" + ), + COURSE_HOME_VIEW_ALL_VIDEOS( + "Course Home.View All Videos", + "edx.bi.app.course.home.view_all_videos.clicked" + ), + COURSE_HOME_VIEW_ALL_ASSIGNMENTS( + "Course Home.View All Assignments", + "edx.bi.app.course.home.view_all_assignments.clicked" + ), + COURSE_HOME_GRADES_VIEW_PROGRESS( + "Course Home.Grades.View Progress", + "edx.bi.app.course.home.grades.view_progress.clicked" + ), + COURSE_HOME_VIDEO_CLICK( + "Course Home.Video Click", + "edx.bi.app.course.home.video.clicked" + ), + COURSE_HOME_ASSIGNMENT_CLICK( + "Course Home.Assignment Click", + "edx.bi.app.course.home.assignment.clicked" + ), } enum class CourseAnalyticsKey(val key: String) { diff --git a/course/src/main/java/org/openedx/course/presentation/CourseRouter.kt b/course/src/main/java/org/openedx/course/presentation/CourseRouter.kt index d600b0897..e09f2ad91 100644 --- a/course/src/main/java/org/openedx/course/presentation/CourseRouter.kt +++ b/course/src/main/java/org/openedx/course/presentation/CourseRouter.kt @@ -1,8 +1,8 @@ package org.openedx.course.presentation import androidx.fragment.app.FragmentManager -import org.openedx.core.presentation.course.CourseViewMode import org.openedx.course.presentation.handouts.HandoutsType +import org.openedx.course.presentation.unit.container.CourseViewMode interface CourseRouter { diff --git a/course/src/main/java/org/openedx/course/presentation/assignments/CourseAssignmentViewModel.kt b/course/src/main/java/org/openedx/course/presentation/assignments/CourseAssignmentViewModel.kt index 1e480e538..11da8d792 100644 --- a/course/src/main/java/org/openedx/course/presentation/assignments/CourseAssignmentViewModel.kt +++ b/course/src/main/java/org/openedx/course/presentation/assignments/CourseAssignmentViewModel.kt @@ -38,7 +38,7 @@ class CourseAssignmentViewModel( private fun collectData() { viewModelScope.launch { - val courseProgressFlow = interactor.getCourseProgress(courseId, false) + val courseProgressFlow = interactor.getCourseProgress(courseId, false, true) val courseStructureFlow = interactor.getCourseStructureFlow(courseId) combine( diff --git a/course/src/main/java/org/openedx/course/presentation/assignments/CourseContentAssignmentScreen.kt b/course/src/main/java/org/openedx/course/presentation/assignments/CourseContentAssignmentScreen.kt index 57d2d5766..ae238e84e 100644 --- a/course/src/main/java/org/openedx/course/presentation/assignments/CourseContentAssignmentScreen.kt +++ b/course/src/main/java/org/openedx/course/presentation/assignments/CourseContentAssignmentScreen.kt @@ -21,6 +21,8 @@ import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyRow import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll import androidx.compose.material.Card import androidx.compose.material.CircularProgressIndicator import androidx.compose.material.Divider @@ -56,7 +58,6 @@ import org.openedx.core.domain.model.Block import org.openedx.core.domain.model.BlockCounts import org.openedx.core.domain.model.CourseProgress import org.openedx.core.domain.model.Progress -import org.openedx.core.presentation.course.CourseViewMode import org.openedx.core.ui.theme.OpenEdXTheme import org.openedx.core.ui.theme.appColors import org.openedx.core.ui.theme.appShapes @@ -65,6 +66,7 @@ import org.openedx.core.utils.TimeUtils import org.openedx.course.R import org.openedx.course.presentation.contenttab.CourseContentAssignmentEmptyState import org.openedx.course.presentation.ui.CourseProgress +import org.openedx.course.presentation.unit.container.CourseViewMode import org.openedx.foundation.presentation.WindowSize import org.openedx.foundation.presentation.WindowType import org.openedx.foundation.presentation.windowSizeValue @@ -132,6 +134,7 @@ private fun CourseContentAssignmentScreen( is CourseAssignmentUIState.Empty -> { CourseContentAssignmentEmptyState( + modifier = Modifier.verticalScroll(rememberScrollState()), onReturnToCourseClick = onNavigateToHome ) } @@ -145,7 +148,7 @@ private fun CourseContentAssignmentScreen( ) { val progress = uiState.progress val description = stringResource( - id = R.string.course_completed, + id = R.string.course_completed_of, progress.completed, progress.total ) @@ -265,7 +268,7 @@ private fun AssignmentGroupSection( if (isCompletedShown || progress.value != 1f) { if (assignments.size > 1) { LazyRow( - horizontalArrangement = Arrangement.spacedBy(8.dp), + horizontalArrangement = Arrangement.spacedBy(12.dp), modifier = Modifier.padding(vertical = 8.dp), contentPadding = PaddingValues(horizontal = 24.dp) ) { @@ -274,11 +277,7 @@ private fun AssignmentGroupSection( assignment = assignment, isSelected = assignment.id == selectedId, onClick = { - selectedId = if (selectedId == assignment.id) { - null - } else { - assignment.id - } + selectedId = assignment.id } ) } @@ -300,7 +299,7 @@ private fun AssignmentGroupSection( AssignmentDetails( modifier = Modifier .padding(horizontal = 24.dp) - .padding(top = 8.dp), + .padding(top = 12.dp), assignment = assignment, sectionName = sectionNames[assignment.id] ?: "", onAssignmentClick = onAssignmentClick @@ -426,7 +425,7 @@ private fun AssignmentDetails( val color = when { assignment.isCompleted() -> MaterialTheme.appColors.successGreen isDuePast -> MaterialTheme.appColors.warning - else -> MaterialTheme.appColors.cardViewBorder + else -> MaterialTheme.appColors.assignmentCardBorder } val label = assignment.assignmentProgress?.label val description = when { @@ -492,12 +491,14 @@ private fun AssignmentDetails( color = MaterialTheme.appColors.textDark ) Text( + modifier = Modifier.padding(top = 4.dp), text = assignment.displayName, style = MaterialTheme.appTypography.bodyLarge, color = MaterialTheme.appColors.textDark ) if (description.isNotEmpty()) { Text( + modifier = Modifier.padding(top = 6.dp), text = description, style = MaterialTheme.appTypography.bodySmall, color = MaterialTheme.appColors.textDark diff --git a/course/src/main/java/org/openedx/course/presentation/container/CourseContainerFragment.kt b/course/src/main/java/org/openedx/course/presentation/container/CourseContainerFragment.kt index 6280cd2fb..57e0a3be4 100644 --- a/course/src/main/java/org/openedx/course/presentation/container/CourseContainerFragment.kt +++ b/course/src/main/java/org/openedx/course/presentation/container/CourseContainerFragment.kt @@ -13,6 +13,7 @@ import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.navigationBarsPadding @@ -26,6 +27,8 @@ import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll import androidx.compose.material.Divider import androidx.compose.material.ExperimentalMaterialApi +import androidx.compose.material.Icon +import androidx.compose.material.IconButton import androidx.compose.material.MaterialTheme import androidx.compose.material.Scaffold import androidx.compose.material.SnackbarData @@ -34,6 +37,9 @@ import androidx.compose.material.SnackbarHost import androidx.compose.material.SnackbarHostState import androidx.compose.material.Text import androidx.compose.material.TextButton +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBackIos +import androidx.compose.material.icons.automirrored.filled.ArrowForwardIos import androidx.compose.material.pullrefresh.PullRefreshIndicator import androidx.compose.material.pullrefresh.pullRefresh import androidx.compose.material.pullrefresh.rememberPullRefreshState @@ -77,6 +83,7 @@ import org.openedx.core.ui.HandleUIMessage import org.openedx.core.ui.IconText import org.openedx.core.ui.OfflineModeDialog import org.openedx.core.ui.OpenEdXButton +import org.openedx.core.ui.PageIndicator import org.openedx.core.ui.RoundTabsBar import org.openedx.core.ui.statusBarsInset import org.openedx.core.ui.theme.OpenEdXTheme @@ -90,6 +97,8 @@ import org.openedx.course.presentation.contenttab.ContentTabScreen import org.openedx.course.presentation.dates.CourseDatesScreen import org.openedx.course.presentation.handouts.HandoutsScreen import org.openedx.course.presentation.handouts.HandoutsType +import org.openedx.course.presentation.home.CourseHomePagerTab +import org.openedx.course.presentation.home.CourseHomeScreen import org.openedx.course.presentation.offline.CourseOfflineScreen import org.openedx.course.presentation.progress.CourseProgressScreen import org.openedx.course.presentation.ui.DatesShiftedSnackBar @@ -272,6 +281,10 @@ fun CourseDashboard( initialPage = 0, pageCount = { CourseContentTab.entries.size } ) + val homePagerState = rememberPagerState( + initialPage = 0, + pageCount = { CourseHomePagerTab.entries.size } + ) val accessStatus = viewModel.courseAccessStatus.observeAsState() val tabState = rememberLazyListState() val snackState = remember { SnackbarHostState() } @@ -292,28 +305,14 @@ fun CourseDashboard( scaffoldState = scaffoldState, backgroundColor = MaterialTheme.appColors.background, bottomBar = { + val currentPage = CourseContainerTab.entries[pagerState.currentPage] Box { - if (CourseContainerTab.entries[pagerState.currentPage] == CourseContainerTab.CONTENT && + if (currentPage == CourseContainerTab.CONTENT && selectedContentTab == CourseContentTab.ASSIGNMENTS ) { - Column( - modifier = Modifier.background(MaterialTheme.appColors.background), - horizontalAlignment = Alignment.CenterHorizontally - ) { - Divider(modifier = Modifier.fillMaxWidth()) - TextButton( - onClick = { - scrollToProgress(scope, pagerState) - } - ) { - IconText( - text = stringResource(R.string.course_review_grading_policy), - painter = painterResource(id = coreR.drawable.core_ic_mountains), - color = MaterialTheme.appColors.primary, - textStyle = MaterialTheme.appTypography.labelLarge - ) - } - } + AssignmentsBottomBar(scope = scope, pagerState = pagerState) + } else if (currentPage == CourseContainerTab.HOME) { + HomeNavigationRow(homePagerState = homePagerState) } var isInternetConnectionShown by rememberSaveable { mutableStateOf(false) @@ -411,6 +410,7 @@ fun CourseDashboard( viewModel = viewModel, pagerState = pagerState, contentTabPagerState = contentTabPagerState, + homePagerState = homePagerState, isResumed = isResumed, fragmentManager = fragmentManager, onContentTabSelected = { tab -> @@ -456,6 +456,7 @@ private fun DashboardPager( viewModel: CourseContainerViewModel, pagerState: PagerState, contentTabPagerState: PagerState, + homePagerState: PagerState, isResumed: Boolean, fragmentManager: FragmentManager, onContentTabSelected: (CourseContentTab) -> Unit, @@ -469,7 +470,36 @@ private fun DashboardPager( ) { page -> when (CourseContainerTab.entries[page]) { CourseContainerTab.HOME -> { - // Home tab content will be implemented later + CourseHomeScreen( + windowSize = windowSize, + viewModel = koinViewModel( + parameters = { parametersOf(viewModel.courseId, viewModel.courseName) } + ), + fragmentManager = fragmentManager, + homePagerState = homePagerState, + onResetDatesClick = { + viewModel.onRefresh(CourseContainerTab.DATES) + }, + onNavigateToContent = { contentTab -> + scope.launch { + // First scroll to CONTENT tab + pagerState.scrollToPage( + CourseContainerTab.entries.indexOf(CourseContainerTab.CONTENT) + ) + // Then scroll to the specified content tab + contentTabPagerState.scrollToPage( + CourseContentTab.entries.indexOf(contentTab) + ) + } + }, + onNavigateToProgress = { + scope.launch { + pagerState.scrollToPage( + CourseContainerTab.entries.indexOf(CourseContainerTab.PROGRESS) + ) + } + } + ) } CourseContainerTab.DATES -> { @@ -552,7 +582,7 @@ private fun DashboardPager( onTabSelected = onContentTabSelected, onNavigateToHome = { scope.launch { - pagerState.animateScrollToPage( + pagerState.scrollToPage( CourseContainerTab.entries.indexOf( CourseContainerTab.HOME ) @@ -681,13 +711,101 @@ private fun SetupCourseAccessErrorButtons( @OptIn(ExperimentalFoundationApi::class) private fun scrollToDates(scope: CoroutineScope, pagerState: PagerState) { scope.launch { - pagerState.animateScrollToPage(CourseContainerTab.entries.indexOf(CourseContainerTab.DATES)) + pagerState.scrollToPage(CourseContainerTab.entries.indexOf(CourseContainerTab.DATES)) } } @OptIn(ExperimentalFoundationApi::class) private fun scrollToProgress(scope: CoroutineScope, pagerState: PagerState) { scope.launch { - pagerState.animateScrollToPage(CourseContainerTab.entries.indexOf(CourseContainerTab.PROGRESS)) + pagerState.scrollToPage(CourseContainerTab.entries.indexOf(CourseContainerTab.PROGRESS)) + } +} + +@Composable +private fun HomeNavigationRow(homePagerState: PagerState) { + val homeCoroutineScope = rememberCoroutineScope() + Row( + modifier = Modifier + .fillMaxWidth() + .background(MaterialTheme.appColors.background), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + val isPreviousPageEnabled = homePagerState.currentPage > 0 + IconButton( + modifier = Modifier.size(60.dp), + enabled = homePagerState.currentPage > 0, + onClick = { + homeCoroutineScope.launch { + homePagerState.animateScrollToPage(homePagerState.currentPage - 1) + } + } + ) { + Icon( + modifier = Modifier.size(12.dp), + imageVector = Icons.AutoMirrored.Filled.ArrowBackIos, + contentDescription = stringResource(coreR.string.core_previous), + tint = if (isPreviousPageEnabled) { + MaterialTheme.appColors.textDark + } else { + MaterialTheme.appColors.textFieldHint + } + ) + } + PageIndicator( + modifier = Modifier.padding(vertical = 16.dp), + numberOfPages = CourseHomePagerTab.entries.size, + selectedPage = homePagerState.currentPage, + defaultRadius = 8.dp, + space = 8.dp, + selectedLength = 24.dp, + ) + val isNextPageEnabled = homePagerState.currentPage < CourseHomePagerTab.entries.size - 1 + IconButton( + modifier = Modifier.size(60.dp), + enabled = isNextPageEnabled, + onClick = { + homeCoroutineScope.launch { + homePagerState.animateScrollToPage(homePagerState.currentPage + 1) + } + } + ) { + Icon( + modifier = Modifier.size(12.dp), + imageVector = Icons.AutoMirrored.Filled.ArrowForwardIos, + contentDescription = stringResource(coreR.string.core_next), + tint = if (isNextPageEnabled) { + MaterialTheme.appColors.textDark + } else { + MaterialTheme.appColors.textFieldHint + } + ) + } + } +} + +@Composable +private fun AssignmentsBottomBar( + scope: CoroutineScope, + pagerState: PagerState +) { + Column( + modifier = Modifier.background(MaterialTheme.appColors.background), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Divider(modifier = Modifier.fillMaxWidth()) + TextButton( + onClick = { + scrollToProgress(scope, pagerState) + } + ) { + IconText( + text = stringResource(R.string.course_review_grading_policy), + painter = painterResource(id = coreR.drawable.core_ic_mountains), + color = MaterialTheme.appColors.primary, + textStyle = MaterialTheme.appTypography.labelLarge + ) + } } } diff --git a/course/src/main/java/org/openedx/course/presentation/contenttab/ContentTabEmptyState.kt b/course/src/main/java/org/openedx/course/presentation/contenttab/ContentTabEmptyState.kt index e5926b315..315b817ad 100644 --- a/course/src/main/java/org/openedx/course/presentation/contenttab/ContentTabEmptyState.kt +++ b/course/src/main/java/org/openedx/course/presentation/contenttab/ContentTabEmptyState.kt @@ -8,8 +8,6 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size -import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.verticalScroll import androidx.compose.material.Icon import androidx.compose.material.MaterialTheme import androidx.compose.material.Text @@ -34,15 +32,16 @@ import org.openedx.course.R @Composable fun ContentTabEmptyState( + modifier: Modifier = Modifier, message: String, - onReturnToCourseClick: () -> Unit + onReturnToCourseClick: () -> Unit, + showReturnButton: Boolean = true ) { val configuration = LocalConfiguration.current Column( - modifier = Modifier + modifier = modifier .fillMaxWidth() - .padding(vertical = 24.dp) - .verticalScroll(rememberScrollState()), + .padding(vertical = 24.dp), verticalArrangement = Arrangement.Center, horizontalAlignment = Alignment.CenterHorizontally ) { @@ -64,52 +63,78 @@ fun ContentTabEmptyState( fontWeight = FontWeight.Medium, textAlign = TextAlign.Center ) - Spacer(Modifier.height(16.dp)) - OpenEdXButton( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 24.dp), - textColor = MaterialTheme.appColors.secondaryButtonText, - backgroundColor = MaterialTheme.appColors.secondaryButtonBackground, - onClick = onReturnToCourseClick - ) { - IconText( - text = stringResource(id = R.string.course_return_to_course_home), - icon = Icons.AutoMirrored.Filled.ArrowBack, - color = MaterialTheme.appColors.secondaryButtonText, - textStyle = MaterialTheme.appTypography.labelLarge - ) + if (showReturnButton) { + Spacer(Modifier.height(16.dp)) + OpenEdXButton( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 24.dp), + textColor = MaterialTheme.appColors.secondaryButtonText, + backgroundColor = MaterialTheme.appColors.secondaryButtonBackground, + onClick = onReturnToCourseClick + ) { + IconText( + text = stringResource(id = R.string.course_return_to_course_home), + icon = Icons.AutoMirrored.Filled.ArrowBack, + color = MaterialTheme.appColors.secondaryButtonText, + textStyle = MaterialTheme.appTypography.labelLarge + ) + } } } } @Composable fun CourseContentAllEmptyState( - onReturnToCourseClick: () -> Unit + modifier: Modifier = Modifier, + onReturnToCourseClick: () -> Unit, + showReturnButton: Boolean = true ) { ContentTabEmptyState( + modifier = modifier, message = stringResource(id = org.openedx.core.R.string.core_no_course_content), - onReturnToCourseClick = onReturnToCourseClick + onReturnToCourseClick = onReturnToCourseClick, + showReturnButton = showReturnButton ) } @Composable fun CourseContentVideoEmptyState( - onReturnToCourseClick: () -> Unit + modifier: Modifier = Modifier, + onReturnToCourseClick: () -> Unit, + showReturnButton: Boolean = true ) { ContentTabEmptyState( + modifier = modifier, message = stringResource(id = org.openedx.core.R.string.core_no_videos), - onReturnToCourseClick = onReturnToCourseClick + onReturnToCourseClick = onReturnToCourseClick, + showReturnButton = showReturnButton ) } @Composable fun CourseContentAssignmentEmptyState( - onReturnToCourseClick: () -> Unit + modifier: Modifier = Modifier, + onReturnToCourseClick: () -> Unit, + showReturnButton: Boolean = true ) { ContentTabEmptyState( + modifier = modifier, message = stringResource(id = org.openedx.core.R.string.core_no_assignments), - onReturnToCourseClick = onReturnToCourseClick + onReturnToCourseClick = onReturnToCourseClick, + showReturnButton = showReturnButton + ) +} + +@Composable +fun CourseHomeGradesEmptyState( + modifier: Modifier = Modifier, +) { + ContentTabEmptyState( + modifier = modifier, + message = stringResource(id = R.string.course_progress_no_assignments), + onReturnToCourseClick = {}, + showReturnButton = false ) } @@ -117,6 +142,14 @@ fun CourseContentAssignmentEmptyState( @Composable private fun CourseContentAllEmptyStatePreview() { OpenEdXTheme { - CourseContentAllEmptyState({}) + CourseContentAllEmptyState(onReturnToCourseClick = {}) + } +} + +@Preview +@Composable +private fun CourseHomeGradesEmptyStatePreview() { + OpenEdXTheme { + CourseHomeGradesEmptyState() } } diff --git a/course/src/main/java/org/openedx/course/presentation/dates/CourseDatesScreen.kt b/course/src/main/java/org/openedx/course/presentation/dates/CourseDatesScreen.kt index 45417ab8f..326ff8839 100644 --- a/course/src/main/java/org/openedx/course/presentation/dates/CourseDatesScreen.kt +++ b/course/src/main/java/org/openedx/course/presentation/dates/CourseDatesScreen.kt @@ -67,7 +67,6 @@ import org.openedx.core.domain.model.CourseDatesBannerInfo import org.openedx.core.domain.model.CourseDatesResult import org.openedx.core.domain.model.DatesSection import org.openedx.core.presentation.CoreAnalyticsScreen -import org.openedx.core.presentation.course.CourseViewMode import org.openedx.core.presentation.dialog.alert.ActionDialogFragment import org.openedx.core.presentation.settings.calendarsync.CalendarSyncState import org.openedx.core.ui.CircularProgress @@ -82,6 +81,7 @@ import org.openedx.core.utils.TimeUtils.formatToString import org.openedx.core.utils.clearTime import org.openedx.course.presentation.ui.CourseDatesBanner import org.openedx.course.presentation.ui.CourseDatesBannerTablet +import org.openedx.course.presentation.unit.container.CourseViewMode import org.openedx.foundation.extension.isNotEmptyThenLet import org.openedx.foundation.presentation.UIMessage import org.openedx.foundation.presentation.WindowSize @@ -286,7 +286,12 @@ private fun CourseDatesUI( Row( modifier = Modifier .fillMaxWidth() - .padding(top = 8.dp, start = 16.dp, end = 8.dp, bottom = 8.dp), + .padding( + top = 8.dp, + start = 16.dp, + end = 8.dp, + bottom = 8.dp + ), horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically ) { diff --git a/course/src/main/java/org/openedx/course/presentation/home/AssignmentsHomePagerCardContent.kt b/course/src/main/java/org/openedx/course/presentation/home/AssignmentsHomePagerCardContent.kt new file mode 100644 index 000000000..3c0afb1e1 --- /dev/null +++ b/course/src/main/java/org/openedx/course/presentation/home/AssignmentsHomePagerCardContent.kt @@ -0,0 +1,281 @@ +package org.openedx.course.presentation.home + +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.Card +import androidx.compose.material.Icon +import androidx.compose.material.LinearProgressIndicator +import androidx.compose.material.MaterialTheme +import androidx.compose.material.Text +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.Assignment +import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight +import androidx.compose.material.icons.filled.Timer +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import org.openedx.core.domain.model.Block +import org.openedx.core.ui.theme.appColors +import org.openedx.core.ui.theme.appTypography +import org.openedx.core.utils.TimeUtils +import org.openedx.course.R +import org.openedx.course.presentation.contenttab.CourseContentAssignmentEmptyState +import java.util.Date +import org.openedx.core.R as coreR + +private const val MILLISECONDS_PER_SECOND = 1000 +private const val SECONDS_PER_MINUTE = 60 +private const val MINUTES_PER_HOUR = 60 +private const val HOURS_PER_DAY = 24 + +private const val MILLISECONDS_PER_DAY = + MILLISECONDS_PER_SECOND * SECONDS_PER_MINUTE * MINUTES_PER_HOUR * HOURS_PER_DAY + +@Composable +fun AssignmentsHomePagerCardContent( + uiState: CourseHomeUIState.CourseData, + onAssignmentClick: (Block) -> Unit, + onViewAllAssignmentsClick: () -> Unit, + getBlockParent: (blockId: String) -> Block?, +) { + if (uiState.courseAssignments.isEmpty()) { + CourseContentAssignmentEmptyState( + onReturnToCourseClick = {}, + showReturnButton = false + ) + return + } + + val completedAssignments = uiState.courseAssignments.count { it.isCompleted() } + val totalAssignments = uiState.courseAssignments.size + val firstIncompleteAssignment = uiState.courseAssignments.find { !it.isCompleted() } + + Column( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp) + ) { + // Header with progress + Text( + text = stringResource(R.string.course_container_content_tab_assignment), + style = MaterialTheme.appTypography.titleLarge, + color = MaterialTheme.appColors.textPrimary, + fontWeight = FontWeight.SemiBold + ) + Spacer(modifier = Modifier.height(12.dp)) + + // Progress section + Row( + modifier = Modifier + .fillMaxWidth() + .semantics(mergeDescendants = true) {}, + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + imageVector = Icons.AutoMirrored.Filled.Assignment, + contentDescription = null, + tint = MaterialTheme.appColors.textPrimary, + modifier = Modifier.size(32.dp) + ) + Spacer(modifier = Modifier.width(8.dp)) + Text( + text = "$completedAssignments/$totalAssignments", + style = MaterialTheme.appTypography.displaySmall, + color = MaterialTheme.appColors.textPrimary, + fontWeight = FontWeight.Bold + ) + Spacer(modifier = Modifier.width(8.dp)) + Text( + text = stringResource(R.string.course_assignments_completed), + style = MaterialTheme.appTypography.labelLarge, + color = MaterialTheme.appColors.textPrimaryVariant, + fontWeight = FontWeight.Medium + ) + } + + Spacer(modifier = Modifier.height(8.dp)) + + // Progress bar + LinearProgressIndicator( + modifier = Modifier + .fillMaxWidth() + .height(4.dp) + .clip(CircleShape), + progress = if (totalAssignments > 0) completedAssignments.toFloat() / totalAssignments else 0f, + color = MaterialTheme.appColors.progressBarColor, + backgroundColor = MaterialTheme.appColors.progressBarBackgroundColor + ) + + Spacer(modifier = Modifier.height(20.dp)) + + // First Incomplete Assignment section + if (firstIncompleteAssignment != null) { + AssignmentCard( + assignment = firstIncompleteAssignment, + sectionName = getBlockParent(firstIncompleteAssignment.id)?.displayName ?: "", + onAssignmentClick = onAssignmentClick, + background = MaterialTheme.appColors.background, + ) + } else { + CaughtUpMessage( + message = stringResource(R.string.course_assignments_caught_up) + ) + } + + Spacer(modifier = Modifier.height(8.dp)) + + // View All Assignments button + ViewAllButton( + text = stringResource(R.string.course_view_all_assignments), + onClick = onViewAllAssignmentsClick + ) + } +} + +@Composable +private fun AssignmentCard( + assignment: Block, + sectionName: String, + onAssignmentClick: (Block) -> Unit, + background: Color = MaterialTheme.appColors.surface +) { + val isDuePast = assignment.due != null && assignment.due!! < Date() + + // Header text - "Past Due" or "Due Soon" + val headerText = if (isDuePast) { + stringResource(coreR.string.core_date_type_past_due) + } else { + stringResource(R.string.course_next_assignment) + } + + // Due date status text + val dueDateStatusText = assignment.due?.let { due -> + val formattedDate = TimeUtils.formatToMonthDay(due) + val daysDifference = ((due.time - Date().time) / MILLISECONDS_PER_DAY).toInt() + when { + daysDifference < 0 -> { + // Past due + val daysPastDue = -daysDifference + stringResource( + R.string.course_days_past_due, + daysPastDue, + formattedDate + ) + } + + daysDifference == 0 -> { + // Due today + stringResource( + R.string.course_due_today, + formattedDate + ) + } + + else -> { + // Due in the future + stringResource( + R.string.course_due_in_days, + daysDifference, + formattedDate + ) + } + } + } ?: "" + + Card( + modifier = Modifier + .fillMaxWidth() + .clickable { onAssignmentClick(assignment) }, + backgroundColor = background, + border = BorderStroke(1.dp, MaterialTheme.appColors.cardViewBorder), + shape = RoundedCornerShape(8.dp), + elevation = 0.dp + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp) + ) { + // Header section with icon and status + if (assignment.due != null) { + Row( + verticalAlignment = Alignment.CenterVertically + ) { + if (isDuePast) { + Icon( + imageVector = Icons.Filled.Timer, + contentDescription = null, + modifier = Modifier.size(24.dp), + tint = MaterialTheme.appColors.warning + ) + Spacer(modifier = Modifier.width(8.dp)) + } + Text( + text = headerText, + style = MaterialTheme.appTypography.titleMedium, + color = MaterialTheme.appColors.textPrimary, + fontWeight = FontWeight.Bold + ) + } + Spacer(modifier = Modifier.height(8.dp)) + } + + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically + ) { + Column( + modifier = Modifier.weight(1f) + ) { + // Due date status text + if (dueDateStatusText.isNotEmpty()) { + Text( + text = dueDateStatusText, + style = MaterialTheme.appTypography.labelSmall, + color = MaterialTheme.appColors.primary + ) + Spacer(modifier = Modifier.height(4.dp)) + } + + // Assignment and section name + Text( + text = assignment.displayName, + style = MaterialTheme.appTypography.titleSmall, + color = MaterialTheme.appColors.textPrimary, + fontWeight = FontWeight.Bold + ) + Spacer(modifier = Modifier.height(4.dp)) + Text( + text = sectionName, + style = MaterialTheme.appTypography.labelSmall, + color = MaterialTheme.appColors.textPrimaryVariant, + ) + } + + // Chevron arrow + Icon( + imageVector = Icons.AutoMirrored.Filled.KeyboardArrowRight, + contentDescription = null, + modifier = Modifier.size(24.dp), + tint = MaterialTheme.appColors.textDark + ) + } + } + } +} diff --git a/course/src/main/java/org/openedx/course/presentation/home/CourseCompletionHomePagerCardContent.kt b/course/src/main/java/org/openedx/course/presentation/home/CourseCompletionHomePagerCardContent.kt new file mode 100644 index 000000000..031a3a145 --- /dev/null +++ b/course/src/main/java/org/openedx/course/presentation/home/CourseCompletionHomePagerCardContent.kt @@ -0,0 +1,161 @@ +package org.openedx.course.presentation.home + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.material.MaterialTheme +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import org.openedx.core.Mock +import org.openedx.core.domain.model.Block +import org.openedx.core.domain.model.CourseDatesBannerInfo +import org.openedx.core.ui.theme.OpenEdXTheme +import org.openedx.core.ui.theme.appColors +import org.openedx.core.ui.theme.appTypography +import org.openedx.course.R +import org.openedx.course.presentation.progress.CourseCompletionCircularProgress +import org.openedx.course.presentation.ui.CourseSection + +@Composable +fun CourseCompletionHomePagerCardContent( + modifier: Modifier = Modifier, + uiState: CourseHomeUIState.CourseData, + onViewAllContentClick: () -> Unit, + onDownloadClick: (blockIds: List) -> Unit, + onSubSectionClick: (Block) -> Unit, +) { + val courseProgress = uiState.courseProgress?.completion ?: 0f + val courseProgressPercent = uiState.courseProgress?.completionPercent ?: 0 + + Column( + modifier = modifier + .fillMaxWidth() + .padding(16.dp) + ) { + // Title + Text( + text = stringResource(R.string.course_completion_title), + style = MaterialTheme.appTypography.titleLarge, + color = MaterialTheme.appColors.textDark + ) + + Spacer(modifier = Modifier.height(12.dp)) + + // Progress Section + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Column( + modifier = Modifier + .weight(1f) + .semantics(mergeDescendants = true) {}, + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + Text( + text = stringResource(R.string.course_completion_progress_label), + style = MaterialTheme.appTypography.labelLarge, + color = MaterialTheme.appColors.textDark, + fontWeight = FontWeight.Bold + ) + Text( + text = stringResource( + R.string.course_completion_progress_description, + courseProgressPercent + ), + style = MaterialTheme.appTypography.bodyMedium, + color = MaterialTheme.appColors.textDark + ) + } + + // Circular Progress + CourseCompletionCircularProgress( + progress = courseProgress, + progressPercent = courseProgressPercent, + completedText = stringResource(R.string.course_completion_completed) + ) + } + + Spacer(modifier = Modifier.height(16.dp)) + + uiState.next?.let { (chapter, subsection) -> + // Section progress + val subSections = uiState.courseSubSections[chapter.id] + val completedCount = subSections?.count { it.isCompleted() } ?: 0 + val totalCount = subSections?.size ?: 0 + val progress = if (totalCount > 0) completedCount.toFloat() / totalCount else 0f + + CourseSection( + section = chapter, + onItemClick = { + onSubSectionClick(subsection) + }, + isExpandable = false, + isSectionVisible = true, + showDueDate = false, + useRelativeDates = uiState.useRelativeDates, + subSections = listOf(subsection), + downloadedStateMap = uiState.downloadedState, + onSubSectionClick = onSubSectionClick, + onDownloadClick = onDownloadClick, + progress = progress, + background = MaterialTheme.appColors.background + ) + } + + Spacer(modifier = Modifier.height(8.dp)) + + // View All Content Button + ViewAllButton( + text = stringResource(R.string.course_completion_view_all_content), + onClick = onViewAllContentClick, + modifier = Modifier.align(Alignment.CenterHorizontally) + ) + } +} + +@Preview +@Composable +private fun CourseCompletionHomePagerCardContentPreview() { + OpenEdXTheme { + CourseCompletionHomePagerCardContent( + uiState = CourseHomeUIState.CourseData( + courseStructure = Mock.mockCourseStructure, + courseProgress = null, // No course progress for preview + next = Pair(Mock.mockChapterBlock, Mock.mockChapterBlock), // Mock next section + downloadedState = mapOf(), + resumeComponent = Mock.mockChapterBlock, + resumeUnitTitle = "Resumed Unit", + courseSubSections = mapOf(), + subSectionsDownloadsCount = mapOf(), + datesBannerInfo = CourseDatesBannerInfo( + missedDeadlines = false, + missedGatedContent = false, + verifiedUpgradeLink = "", + contentTypeGatingEnabled = false, + hasEnded = false + ), + useRelativeDates = true, + courseVideos = mapOf(), + courseAssignments = emptyList(), + videoPreview = null, + videoProgress = 0f + ), + onViewAllContentClick = {}, + onDownloadClick = {}, + onSubSectionClick = {}, + ) + } +} diff --git a/course/src/main/java/org/openedx/course/presentation/home/CourseHomePagerTab.kt b/course/src/main/java/org/openedx/course/presentation/home/CourseHomePagerTab.kt new file mode 100644 index 000000000..d18dad224 --- /dev/null +++ b/course/src/main/java/org/openedx/course/presentation/home/CourseHomePagerTab.kt @@ -0,0 +1,8 @@ +package org.openedx.course.presentation.home + +enum class CourseHomePagerTab { + COURSE_COMPLETION, + VIDEOS, + ASSIGNMENT, + GRADES +} diff --git a/course/src/main/java/org/openedx/course/presentation/home/CourseHomeScreen.kt b/course/src/main/java/org/openedx/course/presentation/home/CourseHomeScreen.kt new file mode 100644 index 000000000..241d51f31 --- /dev/null +++ b/course/src/main/java/org/openedx/course/presentation/home/CourseHomeScreen.kt @@ -0,0 +1,547 @@ +package org.openedx.course.presentation.home + +import android.content.res.Configuration.UI_MODE_NIGHT_NO +import android.content.res.Configuration.UI_MODE_NIGHT_YES +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.pager.HorizontalPager +import androidx.compose.foundation.pager.PagerState +import androidx.compose.foundation.pager.rememberPagerState +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.Card +import androidx.compose.material.Icon +import androidx.compose.material.MaterialTheme +import androidx.compose.material.Scaffold +import androidx.compose.material.Surface +import androidx.compose.material.Text +import androidx.compose.material.TextButton +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.List +import androidx.compose.material.rememberScaffoldState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.AndroidUriHandler +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.Devices +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import androidx.fragment.app.FragmentManager +import org.openedx.core.Mock +import org.openedx.core.NoContentScreenType +import org.openedx.core.domain.model.Block +import org.openedx.core.domain.model.CourseDatesBannerInfo +import org.openedx.core.ui.CircularProgress +import org.openedx.core.ui.HandleUIMessage +import org.openedx.core.ui.NoContentScreen +import org.openedx.core.ui.displayCutoutForLandscape +import org.openedx.core.ui.theme.OpenEdXTheme +import org.openedx.core.ui.theme.appColors +import org.openedx.core.ui.theme.appShapes +import org.openedx.core.ui.theme.appTypography +import org.openedx.course.R +import org.openedx.course.presentation.container.CourseContentTab +import org.openedx.course.presentation.ui.CourseDatesBanner +import org.openedx.course.presentation.ui.CourseDatesBannerTablet +import org.openedx.course.presentation.ui.CourseMessage +import org.openedx.course.presentation.ui.ResumeCourseButton +import org.openedx.course.presentation.unit.container.CourseViewMode +import org.openedx.foundation.extension.takeIfNotEmpty +import org.openedx.foundation.presentation.UIMessage +import org.openedx.foundation.presentation.WindowSize +import org.openedx.foundation.presentation.WindowType +import org.openedx.foundation.presentation.windowSizeValue +import org.openedx.core.R as coreR + +@Composable +fun CourseHomeScreen( + windowSize: WindowSize, + viewModel: CourseHomeViewModel, + fragmentManager: FragmentManager, + homePagerState: PagerState, + onResetDatesClick: () -> Unit, + onNavigateToContent: (CourseContentTab) -> Unit = {}, + onNavigateToProgress: () -> Unit = {}, +) { + val uiState by viewModel.uiState.collectAsState() + val uiMessage by viewModel.uiMessage.collectAsState(null) + val resumeBlockId by viewModel.resumeBlockId.collectAsState("") + val context = LocalContext.current + + LaunchedEffect(resumeBlockId) { + if (resumeBlockId.isNotEmpty()) { + viewModel.openBlock(fragmentManager, resumeBlockId) + } + } + + CourseHomeUI( + windowSize = windowSize, + uiState = uiState, + uiMessage = uiMessage, + homePagerState = homePagerState, + onSubSectionClick = { subSectionBlock -> + // Log section/subsection click event + viewModel.logSectionSubsectionClick( + subSectionBlock.blockId, + subSectionBlock.displayName + ) + if (viewModel.isCourseDropdownNavigationEnabled) { + viewModel.courseSubSectionUnit[subSectionBlock.id]?.let { unit -> + viewModel.courseRouter.navigateToCourseContainer( + fragmentManager, + courseId = viewModel.courseId, + unitId = unit.id, + mode = CourseViewMode.FULL + ) + } + } else { + viewModel.courseRouter.navigateToCourseSubsections( + fm = fragmentManager, + courseId = viewModel.courseId, + subSectionId = subSectionBlock.id, + mode = CourseViewMode.FULL + ) + } + }, + onResumeClick = { componentId -> + viewModel.openBlock( + fragmentManager, + componentId + ) + }, + onDownloadClick = { blocksIds -> + viewModel.downloadBlocks( + blocksIds = blocksIds, + fragmentManager = fragmentManager, + ) + }, + onResetDatesClick = { + viewModel.resetCourseDatesBanner( + onResetDates = { + onResetDatesClick() + } + ) + }, + onCertificateClick = { + viewModel.viewCertificateTappedEvent() + it.takeIfNotEmpty() + ?.let { url -> AndroidUriHandler(context).openUri(url) } + }, + onVideoClick = { videoBlock -> + viewModel.courseRouter.navigateToCourseContainer( + fragmentManager, + courseId = viewModel.courseId, + unitId = viewModel.getBlockParent(videoBlock.id)?.id ?: return@CourseHomeUI, + mode = CourseViewMode.VIDEOS + ) + viewModel.logVideoClick(videoBlock.id) + }, + onAssignmentClick = { assignmentBlock -> + viewModel.courseRouter.navigateToCourseContainer( + fragmentManager, + courseId = viewModel.courseId, + unitId = viewModel.getBlockParent(assignmentBlock.id)?.id ?: return@CourseHomeUI, + mode = CourseViewMode.FULL + ) + viewModel.logAssignmentClick(assignmentBlock.id) + }, + onNavigateToContent = onNavigateToContent, + onNavigateToProgress = onNavigateToProgress, + getBlockParent = viewModel::getBlockParent, + onViewAllContentClick = viewModel::logViewAllContentClick, + onViewAllVideosClick = viewModel::logViewAllVideosClick, + onViewAllAssignmentsClick = viewModel::logViewAllAssignmentsClick, + onViewProgressClick = viewModel::logViewProgressClick + ) +} + +@Composable +private fun CourseHomeUI( + windowSize: WindowSize, + uiState: CourseHomeUIState, + uiMessage: UIMessage?, + homePagerState: PagerState, + onSubSectionClick: (Block) -> Unit, + onResumeClick: (String) -> Unit, + onDownloadClick: (blockIds: List) -> Unit, + onResetDatesClick: () -> Unit, + onCertificateClick: (String) -> Unit, + onVideoClick: (Block) -> Unit, + onAssignmentClick: (Block) -> Unit, + onNavigateToContent: (CourseContentTab) -> Unit, + onNavigateToProgress: () -> Unit, + getBlockParent: (blockId: String) -> Block?, + onViewAllContentClick: () -> Unit, + onViewAllVideosClick: () -> Unit, + onViewAllAssignmentsClick: () -> Unit, + onViewProgressClick: () -> Unit, +) { + val scaffoldState = rememberScaffoldState() + + Scaffold( + modifier = Modifier + .fillMaxSize(), + scaffoldState = scaffoldState, + backgroundColor = MaterialTheme.appColors.background + ) { + val screenWidth by remember(key1 = windowSize) { + mutableStateOf( + windowSize.windowSizeValue( + expanded = Modifier.widthIn(Dp.Unspecified, 560.dp), + compact = Modifier.fillMaxWidth() + ) + ) + } + + HandleUIMessage(uiMessage = uiMessage, scaffoldState = scaffoldState) + + Box( + modifier = Modifier + .fillMaxSize() + .padding(it) + .displayCutoutForLandscape(), + contentAlignment = Alignment.TopCenter + ) { + Surface( + modifier = screenWidth, + color = MaterialTheme.appColors.background + ) { + when (uiState) { + is CourseHomeUIState.CourseData -> { + Column( + modifier = Modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()), + ) { + if (uiState.datesBannerInfo.isBannerAvailableForDashboard()) { + Box( + modifier = Modifier + .padding(all = 8.dp) + ) { + if (windowSize.isTablet) { + CourseDatesBannerTablet( + banner = uiState.datesBannerInfo, + resetDates = onResetDatesClick, + ) + } else { + CourseDatesBanner( + banner = uiState.datesBannerInfo, + resetDates = onResetDatesClick, + ) + } + } + } + + val certificate = uiState.courseStructure.certificate + if (certificate?.isCertificateEarned() == true) { + CourseMessage( + modifier = Modifier + .fillMaxWidth() + .padding( + vertical = 12.dp, + horizontal = 24.dp + ), + icon = painterResource(R.drawable.course_ic_certificate), + message = stringResource( + R.string.course_you_earned_certificate, + uiState.courseStructure.name + ), + action = stringResource(R.string.course_view_certificate), + onActionClick = { + onCertificateClick( + certificate.certificateURL ?: "" + ) + } + ) + } + + if (uiState.resumeComponent != null) { + ResumeCourseButton( + modifier = Modifier.padding(16.dp), + block = uiState.resumeComponent, + displayName = uiState.resumeUnitTitle, + onResumeClick = onResumeClick + ) + } + + Spacer(modifier = Modifier.height(12.dp)) + CourseHomePager( + modifier = Modifier.fillMaxSize(), + pages = CourseHomePagerTab.entries, + pagerState = homePagerState + ) { tab -> + Card( + modifier = Modifier.fillMaxWidth(), + backgroundColor = MaterialTheme.appColors.cardViewBackground, + border = BorderStroke( + 1.dp, + MaterialTheme.appColors.cardViewBorder + ), + shape = MaterialTheme.appShapes.cardShape, + elevation = 0.dp, + ) { + when (tab) { + CourseHomePagerTab.COURSE_COMPLETION -> { + CourseCompletionHomePagerCardContent( + uiState = uiState, + onViewAllContentClick = { + onViewAllContentClick() + onNavigateToContent(CourseContentTab.ALL) + }, + onDownloadClick = onDownloadClick, + onSubSectionClick = onSubSectionClick + ) + } + + CourseHomePagerTab.VIDEOS -> { + VideosHomePagerCardContent( + uiState = uiState, + onVideoClick = onVideoClick, + onViewAllVideosClick = { + onViewAllVideosClick() + onNavigateToContent(CourseContentTab.VIDEOS) + } + ) + } + + CourseHomePagerTab.ASSIGNMENT -> { + AssignmentsHomePagerCardContent( + uiState = uiState, + onAssignmentClick = onAssignmentClick, + getBlockParent = getBlockParent, + onViewAllAssignmentsClick = { + onViewAllAssignmentsClick() + onNavigateToContent(CourseContentTab.ASSIGNMENTS) + } + ) + } + + CourseHomePagerTab.GRADES -> { + GradesHomePagerCardContent( + uiState = uiState, + onViewProgressClick = { + onViewProgressClick() + onNavigateToProgress() + } + ) + } + } + } + } + } + } + + CourseHomeUIState.Error -> { + NoContentScreen(noContentScreenType = NoContentScreenType.COURSE_OUTLINE) + } + + CourseHomeUIState.Loading -> { + CircularProgress() + } + + CourseHomeUIState.Waiting -> {} + } + } + } + } +} + +@Composable +fun CourseHomePager( + modifier: Modifier = Modifier, + pages: List, + pagerState: PagerState, + pageContent: @Composable (T) -> Unit +) { + HorizontalPager( + modifier = modifier, + state = pagerState, + contentPadding = PaddingValues(horizontal = 16.dp), + pageSpacing = 8.dp, + beyondViewportPageCount = pages.size, + verticalAlignment = Alignment.Top + ) { page -> + pageContent(pages[page]) + } +} + +@Composable +fun ViewAllButton( + text: String, + onClick: () -> Unit, + modifier: Modifier = Modifier, +) { + TextButton( + onClick = onClick, + modifier = modifier.fillMaxWidth() + ) { + Icon( + imageVector = Icons.AutoMirrored.Filled.List, + contentDescription = null, + tint = MaterialTheme.appColors.textAccent, + modifier = Modifier.size(20.dp) + ) + Spacer(modifier = Modifier.width(8.dp)) + Text( + text = text, + style = MaterialTheme.appTypography.labelLarge, + color = MaterialTheme.appColors.textAccent + ) + } +} + +@Composable +fun CaughtUpMessage( + modifier: Modifier = Modifier, + message: String, +) { + Column( + modifier = Modifier.fillMaxWidth(), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + Icon( + modifier = Modifier.size(48.dp), + painter = painterResource(coreR.drawable.core_ic_check), + contentDescription = null, + tint = MaterialTheme.appColors.successGreen + ) + Text( + modifier = modifier + .fillMaxWidth(), + text = message, + color = MaterialTheme.appColors.textPrimary, + style = MaterialTheme.appTypography.bodyLarge, + fontWeight = FontWeight.SemiBold, + textAlign = TextAlign.Center + ) + } +} + +@Preview(uiMode = UI_MODE_NIGHT_NO) +@Preview(uiMode = UI_MODE_NIGHT_YES) +@Composable +private fun CourseHomeScreenPreview() { + OpenEdXTheme { + val previewPagerState = rememberPagerState( + initialPage = 0, + pageCount = { CourseHomePagerTab.entries.size } + ) + CourseHomeUI( + windowSize = WindowSize(WindowType.Compact, WindowType.Compact), + uiState = CourseHomeUIState.CourseData( + courseStructure = Mock.mockCourseStructure, + courseProgress = null, // No course progress for preview + next = null, // No next section for preview + downloadedState = mapOf(), + resumeComponent = Mock.mockChapterBlock, + resumeUnitTitle = "Resumed Unit", + courseSubSections = mapOf(), + subSectionsDownloadsCount = mapOf(), + datesBannerInfo = CourseDatesBannerInfo( + missedDeadlines = false, + missedGatedContent = false, + verifiedUpgradeLink = "", + contentTypeGatingEnabled = false, + hasEnded = false + ), + useRelativeDates = true, + courseVideos = mapOf(), + courseAssignments = emptyList(), + videoPreview = null, + videoProgress = 0f + ), + uiMessage = null, + homePagerState = previewPagerState, + onSubSectionClick = {}, + onResumeClick = {}, + onDownloadClick = {}, + onResetDatesClick = {}, + onCertificateClick = {}, + onVideoClick = {}, + onAssignmentClick = {}, + onNavigateToContent = { _ -> }, + onNavigateToProgress = {}, + getBlockParent = { null }, + onViewAllContentClick = {}, + onViewAllVideosClick = {}, + onViewAllAssignmentsClick = {}, + onViewProgressClick = {}, + ) + } +} + +@Preview(uiMode = UI_MODE_NIGHT_NO, device = Devices.NEXUS_9) +@Preview(uiMode = UI_MODE_NIGHT_YES, device = Devices.NEXUS_9) +@Composable +private fun CourseHomeScreenTabletPreview() { + OpenEdXTheme { + val previewPagerState = rememberPagerState( + initialPage = 0, + pageCount = { CourseHomePagerTab.entries.size } + ) + CourseHomeUI( + windowSize = WindowSize(WindowType.Medium, WindowType.Medium), + uiState = CourseHomeUIState.CourseData( + courseStructure = Mock.mockCourseStructure, + courseProgress = null, // No course progress for preview + next = null, // No next section for preview + downloadedState = mapOf(), + resumeComponent = Mock.mockChapterBlock, + resumeUnitTitle = "Resumed Unit", + courseSubSections = mapOf(), + subSectionsDownloadsCount = mapOf(), + datesBannerInfo = CourseDatesBannerInfo( + missedDeadlines = false, + missedGatedContent = false, + verifiedUpgradeLink = "", + contentTypeGatingEnabled = false, + hasEnded = false + ), + useRelativeDates = true, + courseVideos = mapOf(), + courseAssignments = emptyList(), + videoPreview = null, + videoProgress = 0f + ), + uiMessage = null, + homePagerState = previewPagerState, + onSubSectionClick = {}, + onResumeClick = {}, + onDownloadClick = {}, + onResetDatesClick = {}, + onCertificateClick = {}, + onVideoClick = {}, + onAssignmentClick = {}, + onNavigateToContent = { _ -> }, + onNavigateToProgress = { }, + getBlockParent = { null }, + onViewAllContentClick = {}, + onViewAllVideosClick = {}, + onViewAllAssignmentsClick = {}, + onViewProgressClick = {}, + ) + } +} diff --git a/course/src/main/java/org/openedx/course/presentation/home/CourseHomeUIState.kt b/course/src/main/java/org/openedx/course/presentation/home/CourseHomeUIState.kt new file mode 100644 index 000000000..773cb07df --- /dev/null +++ b/course/src/main/java/org/openedx/course/presentation/home/CourseHomeUIState.kt @@ -0,0 +1,31 @@ +package org.openedx.course.presentation.home + +import org.openedx.core.domain.model.Block +import org.openedx.core.domain.model.CourseDatesBannerInfo +import org.openedx.core.domain.model.CourseProgress +import org.openedx.core.domain.model.CourseStructure +import org.openedx.core.module.db.DownloadedState +import org.openedx.core.utils.VideoPreview + +sealed class CourseHomeUIState { + data class CourseData( + val courseStructure: CourseStructure, + val courseProgress: CourseProgress?, + val next: Pair?, // section and subsection, nullable + val downloadedState: Map, + val resumeComponent: Block?, + val resumeUnitTitle: String, + val courseSubSections: Map>, + val subSectionsDownloadsCount: Map, + val datesBannerInfo: CourseDatesBannerInfo, + val useRelativeDates: Boolean, + val courseVideos: Map>, + val courseAssignments: List, + val videoPreview: VideoPreview?, + val videoProgress: Float?, + ) : CourseHomeUIState() + + data object Error : CourseHomeUIState() + data object Loading : CourseHomeUIState() + data object Waiting : CourseHomeUIState() +} diff --git a/course/src/main/java/org/openedx/course/presentation/home/CourseHomeViewModel.kt b/course/src/main/java/org/openedx/course/presentation/home/CourseHomeViewModel.kt new file mode 100644 index 000000000..a5f42db5c --- /dev/null +++ b/course/src/main/java/org/openedx/course/presentation/home/CourseHomeViewModel.kt @@ -0,0 +1,670 @@ +package org.openedx.course.presentation.home + +import android.content.Context +import androidx.fragment.app.FragmentManager +import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.catch +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.launch +import org.openedx.core.BlockType +import org.openedx.core.R +import org.openedx.core.config.Config +import org.openedx.core.data.storage.CorePreferences +import org.openedx.core.domain.model.Block +import org.openedx.core.domain.model.CourseComponentStatus +import org.openedx.core.domain.model.CourseDatesBannerInfo +import org.openedx.core.domain.model.CourseProgress +import org.openedx.core.domain.model.CourseStructure +import org.openedx.core.extension.getChapterBlocks +import org.openedx.core.extension.getSequentialBlocks +import org.openedx.core.extension.getVerticalBlocks +import org.openedx.core.extension.safeDivBy +import org.openedx.core.module.DownloadWorkerController +import org.openedx.core.module.db.DownloadDao +import org.openedx.core.module.download.BaseDownloadViewModel +import org.openedx.core.module.download.DownloadHelper +import org.openedx.core.presentation.CoreAnalytics +import org.openedx.core.presentation.dialog.downloaddialog.DownloadDialogManager +import org.openedx.core.system.connection.NetworkConnection +import org.openedx.core.system.notifier.CourseDatesShifted +import org.openedx.core.system.notifier.CourseNotifier +import org.openedx.core.system.notifier.CourseOpenBlock +import org.openedx.core.system.notifier.CourseProgressLoaded +import org.openedx.core.system.notifier.CourseStructureUpdated +import org.openedx.course.domain.interactor.CourseInteractor +import org.openedx.course.presentation.CourseAnalytics +import org.openedx.course.presentation.CourseAnalyticsEvent +import org.openedx.course.presentation.CourseAnalyticsKey +import org.openedx.course.presentation.CourseRouter +import org.openedx.course.presentation.unit.container.CourseViewMode +import org.openedx.foundation.extension.isInternetError +import org.openedx.foundation.presentation.UIMessage +import org.openedx.foundation.system.ResourceManager +import org.openedx.foundation.utils.FileUtil +import org.openedx.course.R as courseR + +class CourseHomeViewModel( + val courseId: String, + private val courseTitle: String, + private val context: Context, + private val config: Config, + private val interactor: CourseInteractor, + private val resourceManager: ResourceManager, + private val courseNotifier: CourseNotifier, + private val networkConnection: NetworkConnection, + private val preferencesManager: CorePreferences, + private val analytics: CourseAnalytics, + private val downloadDialogManager: DownloadDialogManager, + private val fileUtil: FileUtil, + val courseRouter: CourseRouter, + coreAnalytics: CoreAnalytics, + downloadDao: DownloadDao, + workerController: DownloadWorkerController, + downloadHelper: DownloadHelper, +) : BaseDownloadViewModel( + downloadDao, + preferencesManager, + workerController, + coreAnalytics, + downloadHelper +) { + val isCourseDropdownNavigationEnabled get() = config.getCourseUIConfig().isCourseDropdownNavigationEnabled + + private val _uiState = MutableStateFlow(CourseHomeUIState.Waiting) + val uiState: StateFlow + get() = _uiState.asStateFlow() + + private val _uiMessage = MutableSharedFlow() + val uiMessage: SharedFlow + get() = _uiMessage.asSharedFlow() + + private val _resumeBlockId = MutableSharedFlow() + val resumeBlockId: SharedFlow + get() = _resumeBlockId.asSharedFlow() + + private var resumeSectionBlock: Block? = null + private var resumeVerticalBlock: Block? = null + + private val isCourseExpandableSectionsEnabled get() = config.getCourseUIConfig().isCourseDropdownNavigationEnabled + + private val courseSubSections = mutableMapOf>() + private val subSectionsDownloadsCount = mutableMapOf() + val courseSubSectionUnit = mutableMapOf() + private val courseVideos = mutableMapOf>() + private val courseAssignments = mutableListOf() + + init { + viewModelScope.launch { + courseNotifier.notifier.collect { event -> + when (event) { + is CourseStructureUpdated -> { + if (event.courseId == courseId) { + getCourseData() + } + } + + is CourseOpenBlock -> { + _resumeBlockId.emit(event.blockId) + } + + is CourseProgressLoaded -> { + getCourseProgress() + } + } + } + } + + viewModelScope.launch { + downloadModelsStatusFlow.collect { + if (_uiState.value is CourseHomeUIState.CourseData) { + val state = _uiState.value as CourseHomeUIState.CourseData + _uiState.value = CourseHomeUIState.CourseData( + courseStructure = state.courseStructure, + downloadedState = it.toMap(), + resumeComponent = state.resumeComponent, + resumeUnitTitle = resumeVerticalBlock?.displayName ?: "", + courseSubSections = courseSubSections, + subSectionsDownloadsCount = subSectionsDownloadsCount, + datesBannerInfo = state.datesBannerInfo, + useRelativeDates = preferencesManager.isRelativeDatesEnabled, + next = state.next, + courseProgress = state.courseProgress, + courseVideos = courseVideos, + courseAssignments = courseAssignments, + videoPreview = state.videoPreview, + videoProgress = state.videoProgress + ) + } + } + } + + getCourseData() + } + + override fun saveDownloadModels(folder: String, courseId: String, id: String) { + if (preferencesManager.videoSettings.wifiDownloadOnly) { + if (networkConnection.isWifiConnected()) { + super.saveDownloadModels(folder, courseId, id) + } else { + viewModelScope.launch { + _uiMessage.emit( + UIMessage.ToastMessage( + resourceManager.getString(courseR.string.course_can_download_only_with_wifi) + ) + ) + } + } + } else { + super.saveDownloadModels(folder, courseId, id) + } + } + + fun getCourseData() { + getCourseDataInternal() + } + + private fun getCourseDataInternal() { + viewModelScope.launch { + val courseStructureFlow = interactor.getCourseStructureFlow(courseId, false) + .catch { emit(null) } + val courseStatusFlow = interactor.getCourseStatusFlow(courseId) + val courseDatesFlow = interactor.getCourseDatesFlow(courseId) + val courseProgressFlow = interactor.getCourseProgress(courseId, false, true) + combine( + courseStructureFlow, + courseStatusFlow, + courseDatesFlow, + courseProgressFlow + ) { courseStructure, courseStatus, courseDatesResult, courseProgress -> + if (courseStructure == null) return@combine + val blocks = courseStructure.blockData + val datesBannerInfo = courseDatesResult.courseBanner + + initializeCourseData( + blocks, + courseStructure, + courseStatus, + datesBannerInfo, + courseProgress + ) + }.catch { e -> + handleCourseDataError(e) + }.collect { } + } + } + + private suspend fun initializeCourseData( + blocks: List, + courseStructure: CourseStructure, + courseStatus: CourseComponentStatus, + datesBannerInfo: CourseDatesBannerInfo, + courseProgress: CourseProgress + ) { + setBlocks(blocks) + courseSubSections.clear() + courseSubSectionUnit.clear() + courseVideos.clear() + courseAssignments.clear() + + // Collect all assignments from the original blocks + val allAssignments = blocks + .filter { !it.assignmentProgress?.assignmentType.isNullOrEmpty() } + .filter { it.graded } + .sortedWith( + compareBy { it.due == null } + .thenBy { it.due } + ) + courseAssignments.addAll(allAssignments) + + sortBlocks(blocks) + initDownloadModelsStatus() + val nextSection = findFirstChapterWithIncompleteDescendants(blocks) + + // Get video data + val allVideos = courseVideos.values.flatten() + val firstIncompleteVideo = allVideos.find { !it.isCompleted() } + val videoProgress = if (firstIncompleteVideo != null) { + try { + val videoProgressEntity = interactor.getVideoProgress(firstIncompleteVideo.id) + val videoTime = videoProgressEntity.videoTime?.toFloat() + val videoDuration = videoProgressEntity.duration?.toFloat() + val progress = if (videoTime != null && videoDuration != null) { + videoTime.safeDivBy(videoDuration) + } else { + null + } + progress?.coerceIn(0f, 1f) + } catch (_: Exception) { + 0f + } + } else { + 0f + } + + _uiState.value = CourseHomeUIState.CourseData( + courseStructure = courseStructure, + next = nextSection, + downloadedState = getDownloadModelsStatus(), + resumeComponent = getResumeBlock(blocks, courseStatus.lastVisitedBlockId), + resumeUnitTitle = resumeVerticalBlock?.displayName ?: "", + courseSubSections = courseSubSections, + subSectionsDownloadsCount = subSectionsDownloadsCount, + datesBannerInfo = datesBannerInfo, + useRelativeDates = preferencesManager.isRelativeDatesEnabled, + courseProgress = courseProgress, + courseVideos = courseVideos, + courseAssignments = courseAssignments, + videoPreview = (_uiState.value as? CourseHomeUIState.CourseData)?.videoPreview, + videoProgress = videoProgress + ) + getVideoPreview(firstIncompleteVideo) + } + + private fun getVideoPreview(videoBlock: Block?) { + viewModelScope.launch(Dispatchers.IO) { + val videoPreview = videoBlock?.getVideoPreview( + context, + networkConnection.isOnline(), + null + ) + _uiState.value = (_uiState.value as? CourseHomeUIState.CourseData) + ?.copy( + videoPreview = videoPreview + ) ?: return@launch + } + } + + private suspend fun handleCourseDataError(e: Throwable?) { + _uiState.value = CourseHomeUIState.Error + val errorMessage = when { + e?.isInternetError() == true -> R.string.core_error_no_connection + else -> R.string.core_error_unknown_error + } + _uiMessage.emit(UIMessage.SnackBarMessage(resourceManager.getString(errorMessage))) + } + + private fun sortBlocks(blocks: List): List { + if (blocks.isEmpty()) return emptyList() + + val resultBlocks = mutableListOf() + blocks.forEach { block -> + if (block.type == BlockType.CHAPTER) { + resultBlocks.add(block) + processDescendants(block, blocks) + } + } + return resultBlocks + } + + private fun processDescendants(block: Block, blocks: List) { + block.descendants.forEach { descendantId -> + val sequentialBlock = blocks.find { it.id == descendantId } ?: return@forEach + addSequentialBlockToSubSections(block, sequentialBlock) + courseSubSectionUnit[sequentialBlock.id] = + sequentialBlock.getFirstDescendantBlock(blocks) + subSectionsDownloadsCount[sequentialBlock.id] = + sequentialBlock.getDownloadsCount(blocks) + addDownloadableChildrenForSequentialBlock(sequentialBlock) + + // Add video processing logic + val verticalBlocks = blocks.filter { block -> + block.id in sequentialBlock.descendants + } + val videoBlocks = blocks.filter { block -> + verticalBlocks.any { vertical -> block.id in vertical.descendants } && block.type == BlockType.VIDEO + } + addToVideos(block, videoBlocks) + } + } + + private fun addSequentialBlockToSubSections(block: Block, sequentialBlock: Block) { + courseSubSections.getOrPut(block.id) { mutableListOf() }.add(sequentialBlock) + } + + private fun addToVideos(chapterBlock: Block, videoBlocks: List) { + courseVideos.getOrPut(chapterBlock.id) { mutableListOf() }.addAll(videoBlocks) + } + + fun getBlockParent(blockId: String): Block? { + return allBlocks.values.find { blockId in it.descendants } + } + + private fun getResumeBlock( + blocks: List, + continueBlockId: String, + ): Block? { + val resumeBlock = blocks.firstOrNull { it.id == continueBlockId } + resumeVerticalBlock = + blocks.getVerticalBlocks().find { it.descendants.contains(resumeBlock?.id) } + resumeSectionBlock = + blocks.getSequentialBlocks().find { it.descendants.contains(resumeVerticalBlock?.id) } + return resumeBlock + } + + /** + * Finds the first chapter which has incomplete descendants and returns it as a Pair + * where the first Block is the chapter and the second Block is the first incomplete subsection + */ + private fun findFirstChapterWithIncompleteDescendants(blocks: List): Pair? { + val incompleteChapterBlock = blocks.getChapterBlocks().find { !it.isCompleted() } + val incompleteSubsection = incompleteChapterBlock?.let { + findFirstIncompleteSubsection(it, blocks) + } + return if (incompleteChapterBlock != null && incompleteSubsection != null) { + Pair(incompleteChapterBlock, incompleteSubsection) + } else { + null + } + } + + private fun findFirstIncompleteSubsection(chapter: Block, blocks: List): Block? { + // Get all sequential blocks (subsections) in this chapter + val sequentialBlocks = chapter.descendants.mapNotNull { descendantId -> + blocks.find { it.id == descendantId && it.type == BlockType.SEQUENTIAL } + } + return sequentialBlocks.find { !it.isCompleted() } + } + + fun resetCourseDatesBanner(onResetDates: (Boolean) -> Unit) { + viewModelScope.launch { + try { + interactor.resetCourseDates(courseId = courseId) + getCourseData() + courseNotifier.send(CourseDatesShifted) + onResetDates(true) + } catch (e: Exception) { + if (e.isInternetError()) { + _uiMessage.emit( + UIMessage.SnackBarMessage( + resourceManager.getString(R.string.core_error_no_connection) + ) + ) + } else { + _uiMessage.emit( + UIMessage.SnackBarMessage( + resourceManager.getString(R.string.core_dates_shift_dates_unsuccessful_msg) + ) + ) + } + onResetDates(false) + } + } + } + + fun openBlock(fragmentManager: FragmentManager, blockId: String) { + viewModelScope.launch { + val courseStructure = interactor.getCourseStructure(courseId, false) + val blocks = courseStructure.blockData + getResumeBlock(blocks, blockId) + resumeBlock(fragmentManager, blockId) + } + } + + private fun resumeBlock(fragmentManager: FragmentManager, blockId: String) { + resumeSectionBlock?.let { subSection -> + resumeCourseTappedEvent(subSection.id) + resumeVerticalBlock?.let { unit -> + if (isCourseExpandableSectionsEnabled) { + courseRouter.navigateToCourseContainer( + fm = fragmentManager, + courseId = courseId, + unitId = unit.id, + componentId = blockId, + mode = CourseViewMode.FULL + ) + } else { + courseRouter.navigateToCourseSubsections( + fragmentManager, + courseId = courseId, + subSectionId = subSection.id, + mode = CourseViewMode.FULL, + unitId = unit.id, + componentId = blockId + ) + } + } + } + } + + fun downloadBlocks(blocksIds: List, fragmentManager: FragmentManager) { + viewModelScope.launch { + val courseData = _uiState.value as? CourseHomeUIState.CourseData ?: return@launch + + val subSectionsBlocks = + courseData.courseSubSections.values.flatten().filter { it.id in blocksIds } + + val blocks = subSectionsBlocks.flatMap { subSectionsBlock -> + val verticalBlocks = + allBlocks.values.filter { it.id in subSectionsBlock.descendants } + allBlocks.values.filter { it.id in verticalBlocks.flatMap { it.descendants } } + } + + val downloadableBlocks = blocks.filter { it.isDownloadable } + val downloadingBlocks = blocksIds.filter { isBlockDownloading(it) } + val isAllBlocksDownloaded = downloadableBlocks.all { isBlockDownloaded(it.id) } + + val notDownloadedSubSectionBlocks = subSectionsBlocks.mapNotNull { subSectionsBlock -> + val verticalBlocks = + allBlocks.values.filter { it.id in subSectionsBlock.descendants } + val notDownloadedBlocks = allBlocks.values.filter { + it.id in verticalBlocks.flatMap { it.descendants } && it.isDownloadable && !isBlockDownloaded( + it.id + ) + } + if (notDownloadedBlocks.isNotEmpty()) { + subSectionsBlock + } else { + null + } + } + + val requiredSubSections = notDownloadedSubSectionBlocks.ifEmpty { + subSectionsBlocks + } + + if (downloadingBlocks.isNotEmpty()) { + val downloadableChildren = + downloadingBlocks.flatMap { getDownloadableChildren(it).orEmpty() } + if (config.getCourseUIConfig().isCourseDownloadQueueEnabled) { + courseRouter.navigateToDownloadQueue(fragmentManager, downloadableChildren) + } else { + downloadableChildren.forEach { + if (!isBlockDownloaded(it)) { + removeBlockDownloadModel(it) + } + } + } + } else { + downloadDialogManager.showPopup( + subSectionsBlocks = requiredSubSections, + courseId = courseId, + isBlocksDownloaded = isAllBlocksDownloaded, + fragmentManager = fragmentManager, + removeDownloadModels = ::removeDownloadModels, + saveDownloadModels = { blockId -> + saveDownloadModels(fileUtil.getExternalAppDir().path, courseId, blockId) + } + ) + } + } + } + + fun getCourseProgress() { + viewModelScope.launch { + if (_uiState.value !is CourseHomeUIState.CourseData) { + _uiState.value = CourseHomeUIState.Loading + } + interactor.getCourseProgress(courseId, false, true) + .catch { e -> + if (_uiState.value !is CourseHomeUIState.CourseData) { + _uiState.value = CourseHomeUIState.Error + } + } + .collectLatest { progress -> + val currentState = _uiState.value + if (currentState is CourseHomeUIState.CourseData) { + _uiState.value = currentState.copy(courseProgress = progress) + } + } + } + } + + fun logVideoClick(blockId: String) { + val currentState = uiState.value + if (currentState is CourseHomeUIState.CourseData) { + analytics.logEvent( + CourseAnalyticsEvent.COURSE_HOME_VIDEO_CLICK.eventName, + buildMap { + put( + CourseAnalyticsKey.NAME.key, + CourseAnalyticsEvent.COURSE_HOME_VIDEO_CLICK.biValue + ) + put(CourseAnalyticsKey.COURSE_ID.key, courseId) + put(CourseAnalyticsKey.COURSE_NAME.key, currentState.courseStructure.name) + put(CourseAnalyticsKey.BLOCK_ID.key, blockId) + } + ) + } + } + + fun logAssignmentClick(blockId: String) { + val currentState = uiState.value + if (currentState is CourseHomeUIState.CourseData) { + analytics.logEvent( + CourseAnalyticsEvent.COURSE_HOME_ASSIGNMENT_CLICK.eventName, + buildMap { + put( + CourseAnalyticsKey.NAME.key, + CourseAnalyticsEvent.COURSE_HOME_ASSIGNMENT_CLICK.biValue + ) + put(CourseAnalyticsKey.COURSE_ID.key, courseId) + put(CourseAnalyticsKey.COURSE_NAME.key, currentState.courseStructure.name) + put(CourseAnalyticsKey.BLOCK_ID.key, blockId) + } + ) + } + } + + fun viewCertificateTappedEvent() { + analytics.logEvent( + CourseAnalyticsEvent.VIEW_CERTIFICATE.eventName, + buildMap { + put(CourseAnalyticsKey.NAME.key, CourseAnalyticsEvent.VIEW_CERTIFICATE.biValue) + put(CourseAnalyticsKey.COURSE_ID.key, courseId) + } + ) + } + + private fun resumeCourseTappedEvent(blockId: String) { + val currentState = uiState.value + if (currentState is CourseHomeUIState.CourseData) { + analytics.logEvent( + CourseAnalyticsEvent.RESUME_COURSE_CLICKED.eventName, + buildMap { + put( + CourseAnalyticsKey.NAME.key, + CourseAnalyticsEvent.RESUME_COURSE_CLICKED.biValue + ) + put(CourseAnalyticsKey.COURSE_ID.key, courseId) + put(CourseAnalyticsKey.COURSE_NAME.key, courseTitle) + put(CourseAnalyticsKey.BLOCK_ID.key, blockId) + } + ) + } + } + + fun logSectionSubsectionClick(blockId: String, blockName: String) { + val currentState = uiState.value + if (currentState is CourseHomeUIState.CourseData) { + analytics.logEvent( + CourseAnalyticsEvent.COURSE_HOME_SECTION_SUBSECTION_CLICK.eventName, + buildMap { + put( + CourseAnalyticsKey.NAME.key, + CourseAnalyticsEvent.COURSE_HOME_SECTION_SUBSECTION_CLICK.biValue + ) + put(CourseAnalyticsKey.COURSE_ID.key, courseId) + put(CourseAnalyticsKey.COURSE_NAME.key, currentState.courseStructure.name) + put(CourseAnalyticsKey.BLOCK_ID.key, blockId) + put(CourseAnalyticsKey.BLOCK_NAME.key, blockName) + } + ) + } + } + + fun logViewAllContentClick() { + val currentState = uiState.value + if (currentState is CourseHomeUIState.CourseData) { + analytics.logEvent( + CourseAnalyticsEvent.COURSE_HOME_VIEW_ALL_CONTENT.eventName, + buildMap { + put( + CourseAnalyticsKey.NAME.key, + CourseAnalyticsEvent.COURSE_HOME_VIEW_ALL_CONTENT.biValue + ) + put(CourseAnalyticsKey.COURSE_ID.key, courseId) + put(CourseAnalyticsKey.COURSE_NAME.key, currentState.courseStructure.name) + } + ) + } + } + + fun logViewAllVideosClick() { + val currentState = uiState.value + if (currentState is CourseHomeUIState.CourseData) { + analytics.logEvent( + CourseAnalyticsEvent.COURSE_HOME_VIEW_ALL_VIDEOS.eventName, + buildMap { + put( + CourseAnalyticsKey.NAME.key, + CourseAnalyticsEvent.COURSE_HOME_VIEW_ALL_VIDEOS.biValue + ) + put(CourseAnalyticsKey.COURSE_ID.key, courseId) + put(CourseAnalyticsKey.COURSE_NAME.key, currentState.courseStructure.name) + } + ) + } + } + + fun logViewAllAssignmentsClick() { + val currentState = uiState.value + if (currentState is CourseHomeUIState.CourseData) { + analytics.logEvent( + CourseAnalyticsEvent.COURSE_HOME_VIEW_ALL_ASSIGNMENTS.eventName, + buildMap { + put( + CourseAnalyticsKey.NAME.key, + CourseAnalyticsEvent.COURSE_HOME_VIEW_ALL_ASSIGNMENTS.biValue + ) + put(CourseAnalyticsKey.COURSE_ID.key, courseId) + put(CourseAnalyticsKey.COURSE_NAME.key, currentState.courseStructure.name) + } + ) + } + } + + fun logViewProgressClick() { + val currentState = uiState.value + if (currentState is CourseHomeUIState.CourseData) { + analytics.logEvent( + CourseAnalyticsEvent.COURSE_HOME_GRADES_VIEW_PROGRESS.eventName, + buildMap { + put( + CourseAnalyticsKey.NAME.key, + CourseAnalyticsEvent.COURSE_HOME_GRADES_VIEW_PROGRESS.biValue + ) + put(CourseAnalyticsKey.COURSE_ID.key, courseId) + put(CourseAnalyticsKey.COURSE_NAME.key, currentState.courseStructure.name) + } + ) + } + } +} diff --git a/course/src/main/java/org/openedx/course/presentation/home/GradesHomePagerCardContent.kt b/course/src/main/java/org/openedx/course/presentation/home/GradesHomePagerCardContent.kt new file mode 100644 index 000000000..962732203 --- /dev/null +++ b/course/src/main/java/org/openedx/course/presentation/home/GradesHomePagerCardContent.kt @@ -0,0 +1,221 @@ +package org.openedx.course.presentation.home + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.IntrinsicSize +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material.Card +import androidx.compose.material.MaterialTheme +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import org.openedx.core.domain.model.CourseProgress +import org.openedx.core.domain.model.CourseStructure +import org.openedx.core.ui.theme.appColors +import org.openedx.core.ui.theme.appShapes +import org.openedx.core.ui.theme.appTypography +import org.openedx.course.R +import org.openedx.course.presentation.contenttab.CourseHomeGradesEmptyState +import org.openedx.course.presentation.progress.CurrentOverallGradeText +import org.openedx.course.presentation.progress.GradeProgressBar +import org.openedx.course.presentation.progress.RequiredGradeMarker + +@Composable +fun GradesHomePagerCardContent( + uiState: CourseHomeUIState.CourseData, + onViewProgressClick: () -> Unit +) { + val courseProgress = uiState.courseProgress + val gradingPolicy = courseProgress?.gradingPolicy + val assignmentPolicies = courseProgress?.getNotEmptyGradingPolicies() + val requiredGradeString = stringResource( + R.string.course_progress_required_grade_percent, + courseProgress?.requiredGradePercent.toString() + ) + + if (courseProgress == null || gradingPolicy == null || assignmentPolicies.isNullOrEmpty()) { + CourseHomeGradesEmptyState() + return + } + + Column( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp) + ) { + Text( + text = stringResource(R.string.course_grades_title), + style = MaterialTheme.appTypography.titleLarge, + color = MaterialTheme.appColors.textPrimary, + fontWeight = FontWeight.SemiBold + ) + Spacer(modifier = Modifier.height(16.dp)) + Text( + text = stringResource(R.string.course_grades_description), + style = MaterialTheme.appTypography.labelMedium, + color = MaterialTheme.appColors.textPrimaryVariant, + ) + Spacer(modifier = Modifier.height(16.dp)) + CurrentOverallGradeText(progress = courseProgress) + Spacer(modifier = Modifier.height(12.dp)) + Column( + modifier = Modifier + .semantics { + contentDescription = requiredGradeString + } + ) { + GradeProgressBar( + progress = courseProgress, + gradingPolicy = gradingPolicy, + notCompletedWeightedGradePercent = courseProgress.getNotCompletedWeightedGradePercent() + ) + RequiredGradeMarker(progress = courseProgress) + } + Spacer(modifier = Modifier.height(20.dp)) + GradeCardsGrid( + assignmentPolicies = assignmentPolicies, + assignmentColors = gradingPolicy.assignmentColors, + progress = courseProgress, + courseStructure = uiState.courseStructure + ) + Spacer(modifier = Modifier.height(8.dp)) + ViewAllButton( + text = stringResource(R.string.course_view_progress), + onClick = onViewProgressClick, + ) + } +} + +@Composable +private fun GradeCard( + policy: CourseProgress.GradingPolicy.AssignmentPolicy, + progress: CourseProgress, + courseStructure: CourseStructure?, + color: Color, + modifier: Modifier = Modifier +) { + val assignments = progress.getAssignmentSections(policy.type) + val earned = progress.getCompletedAssignmentCount(policy, courseStructure) + val possible = assignments.size + val gradePercent = if (possible > 0) (earned.toFloat() / possible * 100).toInt() else 0 + + Card( + modifier = modifier + .fillMaxWidth() + .semantics(mergeDescendants = true) {}, + backgroundColor = color.copy(alpha = 0.1f), + shape = MaterialTheme.appShapes.material.small, + elevation = 0.dp, + ) { + Column( + modifier = Modifier + .padding(horizontal = 16.dp) + .padding(vertical = 10.dp) + ) { + // Assignment type title + Text( + text = policy.type, + style = MaterialTheme.appTypography.labelLarge, + color = MaterialTheme.appColors.textPrimary, + fontWeight = FontWeight.Medium, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + + Spacer(modifier = Modifier.height(8.dp)) + + // Grade percentage with colored bar + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .fillMaxWidth() + .height(IntrinsicSize.Min), + ) { + Box( + modifier = Modifier + .width(8.dp) + .fillMaxHeight() + .background( + color = color, + shape = CircleShape + ) + ) + Spacer(modifier = Modifier.width(8.dp)) + Column { + Text( + text = "$gradePercent%", + style = MaterialTheme.appTypography.bodyLarge, + color = MaterialTheme.appColors.textPrimary, + fontWeight = FontWeight.Bold + ) + Spacer(modifier = Modifier.height(4.dp)) + Text( + text = stringResource( + R.string.course_progress_earned_possible_assignment_problems, + earned, + possible + ), + style = MaterialTheme.appTypography.labelSmall, + color = MaterialTheme.appColors.textPrimary, + ) + } + } + } + } +} + +@Composable +private fun GradeCardsGrid( + assignmentPolicies: List, + assignmentColors: List, + progress: CourseProgress, + courseStructure: CourseStructure? +) { + Column( + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + // Group policies into rows of 2 + assignmentPolicies.chunked(2).forEach { rowPolicies -> + Row( + horizontalArrangement = Arrangement.spacedBy(16.dp), + modifier = Modifier.fillMaxWidth() + ) { + rowPolicies.forEachIndexed { index, policy -> + val policyIndex = assignmentPolicies.indexOf(policy) + GradeCard( + modifier = Modifier.weight(1f), + policy = policy, + progress = progress, + courseStructure = courseStructure, + color = if (assignmentColors.isNotEmpty()) { + assignmentColors[policyIndex % assignmentColors.size] + } else { + MaterialTheme.appColors.primary + }, + ) + } + // Fill remaining space if row has only 1 item + if (rowPolicies.size == 1) { + Spacer(modifier = Modifier.weight(1f)) + } + } + } + } +} diff --git a/course/src/main/java/org/openedx/course/presentation/home/VideosHomePagerCardContent.kt b/course/src/main/java/org/openedx/course/presentation/home/VideosHomePagerCardContent.kt new file mode 100644 index 000000000..ecbeec99b --- /dev/null +++ b/course/src/main/java/org/openedx/course/presentation/home/VideosHomePagerCardContent.kt @@ -0,0 +1,184 @@ +package org.openedx.course.presentation.home + +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material.Card +import androidx.compose.material.Icon +import androidx.compose.material.LinearProgressIndicator +import androidx.compose.material.MaterialTheme +import androidx.compose.material.Text +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Videocam +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import org.openedx.core.domain.model.Block +import org.openedx.core.extension.getUnitChapter +import org.openedx.core.ui.theme.appColors +import org.openedx.core.ui.theme.appShapes +import org.openedx.core.ui.theme.appTypography +import org.openedx.course.R +import org.openedx.course.presentation.contenttab.CourseContentVideoEmptyState +import org.openedx.course.presentation.ui.CourseVideoItem + +@Composable +fun VideosHomePagerCardContent( + uiState: CourseHomeUIState.CourseData, + onVideoClick: (Block) -> Unit, + onViewAllVideosClick: () -> Unit +) { + val allVideos = uiState.courseVideos.values.flatten() + if (allVideos.isEmpty()) { + CourseContentVideoEmptyState( + onReturnToCourseClick = {}, + showReturnButton = false + ) + return + } + + val completedVideos = allVideos.count { it.isCompleted() } + val totalVideos = allVideos.size + val firstIncompleteVideo = allVideos.find { !it.isCompleted() } + val videoProgress = uiState.videoProgress ?: if (firstIncompleteVideo?.isCompleted() ?: false) { + 1f + } else { + 0f + } + + Column( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp) + ) { + // Header with progress + Text( + text = stringResource(R.string.course_container_content_tab_video), + style = MaterialTheme.appTypography.titleLarge, + color = MaterialTheme.appColors.textPrimary, + fontWeight = FontWeight.SemiBold + ) + Spacer(modifier = Modifier.height(12.dp)) + Row( + modifier = Modifier + .fillMaxWidth() + .semantics(mergeDescendants = true) {}, + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + imageVector = Icons.Filled.Videocam, + contentDescription = null, + tint = MaterialTheme.appColors.textPrimary, + modifier = Modifier.size(32.dp) + ) + Spacer(modifier = Modifier.width(8.dp)) + Text( + text = "$completedVideos/$totalVideos", + style = MaterialTheme.appTypography.displaySmall, + color = MaterialTheme.appColors.textPrimary, + fontWeight = FontWeight.Bold + ) + Spacer(modifier = Modifier.width(8.dp)) + Text( + text = stringResource(R.string.course_videos_completed), + style = MaterialTheme.appTypography.labelLarge, + color = MaterialTheme.appColors.textPrimaryVariant, + fontWeight = FontWeight.Medium + ) + } + + Spacer(modifier = Modifier.height(8.dp)) + + // Progress bar + LinearProgressIndicator( + modifier = Modifier + .fillMaxWidth() + .height(4.dp) + .clip(CircleShape), + progress = if (totalVideos > 0) completedVideos.toFloat() / totalVideos else 0f, + color = MaterialTheme.appColors.progressBarColor, + backgroundColor = MaterialTheme.appColors.progressBarBackgroundColor + ) + + Spacer(modifier = Modifier.height(20.dp)) + + // Continue Watching section + if (firstIncompleteVideo != null) { + val title = if (videoProgress > 0) { + stringResource(R.string.course_continue_watching) + } else { + stringResource(R.string.course_next_video) + } + Text( + text = title, + style = MaterialTheme.appTypography.titleMedium, + color = MaterialTheme.appColors.textPrimary, + fontWeight = FontWeight.SemiBold + ) + + Spacer(modifier = Modifier.height(8.dp)) + + // Video card using CourseVideoItem + Card( + modifier = Modifier + .fillMaxWidth(), + backgroundColor = MaterialTheme.appColors.cardViewBackground, + shape = MaterialTheme.appShapes.videoPreviewShape, + elevation = 0.dp, + border = BorderStroke( + width = 1.dp, + color = MaterialTheme.appColors.cardViewBorder + ) + ) { + Column { + CourseVideoItem( + modifier = Modifier + .fillMaxWidth() + .height(180.dp), + videoBlock = firstIncompleteVideo, + preview = uiState.videoPreview, + progress = videoProgress, + onClick = { + onVideoClick(firstIncompleteVideo) + }, + titleStyle = MaterialTheme.appTypography.titleMedium, + contentModifier = Modifier + .padding(horizontal = 16.dp, vertical = 8.dp), + progressModifier = Modifier.height(8.dp), + ) + Text( + modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp), + text = uiState.courseStructure.blockData + .getUnitChapter(firstIncompleteVideo.id)?.displayName ?: "", + style = MaterialTheme.appTypography.labelMedium, + color = MaterialTheme.appColors.textPrimary, + ) + } + } + } else { + CaughtUpMessage( + message = stringResource(R.string.course_videos_caught_up) + ) + } + + Spacer(modifier = Modifier.height(8.dp)) + + // View All Videos button + ViewAllButton( + text = stringResource(R.string.course_view_all_videos), + onClick = onViewAllVideosClick + ) + } +} diff --git a/course/src/main/java/org/openedx/course/presentation/outline/CourseContentAllScreen.kt b/course/src/main/java/org/openedx/course/presentation/outline/CourseContentAllScreen.kt index 82e69dfd0..e8355387b 100644 --- a/course/src/main/java/org/openedx/course/presentation/outline/CourseContentAllScreen.kt +++ b/course/src/main/java/org/openedx/course/presentation/outline/CourseContentAllScreen.kt @@ -2,24 +2,20 @@ package org.openedx.course.presentation.outline import android.content.res.Configuration.UI_MODE_NIGHT_NO import android.content.res.Configuration.UI_MODE_NIGHT_YES -import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.PaddingValues -import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.defaultMinSize import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll import androidx.compose.material.MaterialTheme import androidx.compose.material.Scaffold import androidx.compose.material.Surface -import androidx.compose.material.Text -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.automirrored.filled.ArrowForward import androidx.compose.material.rememberScaffoldState import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect @@ -34,31 +30,22 @@ import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.pluralStringResource import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.tooling.preview.Devices import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import androidx.fragment.app.FragmentManager import org.openedx.core.BlockType -import org.openedx.core.domain.model.AssignmentProgress +import org.openedx.core.Mock import org.openedx.core.domain.model.Block -import org.openedx.core.domain.model.BlockCounts import org.openedx.core.domain.model.CourseDatesBannerInfo -import org.openedx.core.domain.model.CourseStructure -import org.openedx.core.domain.model.CoursewareAccess -import org.openedx.core.domain.model.OfflineDownload import org.openedx.core.domain.model.Progress import org.openedx.core.extension.getChapterBlocks -import org.openedx.core.presentation.course.CourseViewMode import org.openedx.core.ui.CircularProgress import org.openedx.core.ui.HandleUIMessage -import org.openedx.core.ui.OpenEdXButton -import org.openedx.core.ui.TextIcon import org.openedx.core.ui.displayCutoutForLandscape import org.openedx.core.ui.theme.OpenEdXTheme import org.openedx.core.ui.theme.appColors -import org.openedx.core.ui.theme.appTypography import org.openedx.course.R import org.openedx.course.presentation.contenttab.CourseContentAllEmptyState import org.openedx.course.presentation.ui.CourseDatesBanner @@ -66,12 +53,13 @@ import org.openedx.course.presentation.ui.CourseDatesBannerTablet import org.openedx.course.presentation.ui.CourseMessage import org.openedx.course.presentation.ui.CourseProgress import org.openedx.course.presentation.ui.CourseSection +import org.openedx.course.presentation.ui.ResumeCourseButton +import org.openedx.course.presentation.unit.container.CourseViewMode import org.openedx.foundation.extension.takeIfNotEmpty import org.openedx.foundation.presentation.UIMessage import org.openedx.foundation.presentation.WindowSize import org.openedx.foundation.presentation.WindowType import org.openedx.foundation.presentation.windowSizeValue -import java.util.Date @Composable fun CourseContentAllScreen( @@ -298,7 +286,7 @@ private fun CourseContentAllUI( if (uiState.resumeComponent != null) { item { Box(listPadding) { - ResumeCourse( + ResumeCourseButton( modifier = Modifier.padding(vertical = 16.dp), block = uiState.resumeComponent, displayName = uiState.resumeUnitTitle, @@ -320,11 +308,11 @@ private fun CourseContentAllUI( item { CourseSection( modifier = listPadding.padding(vertical = 4.dp), - block = section, + section = section, onItemClick = onExpandClick, useRelativeDates = uiState.useRelativeDates, isSectionVisible = courseSectionsState, - courseSubSections = courseSubSections, + subSections = courseSubSections, downloadedStateMap = uiState.downloadedState, onSubSectionClick = onSubSectionClick, onDownloadClick = onDownloadClick @@ -337,6 +325,7 @@ private fun CourseContentAllUI( CourseContentAllUIState.Error -> { CourseContentAllEmptyState( + modifier = Modifier.verticalScroll(rememberScrollState()), onReturnToCourseClick = onNavigateToHome ) } @@ -351,44 +340,6 @@ private fun CourseContentAllUI( } } -@Composable -private fun ResumeCourse( - modifier: Modifier = Modifier, - block: Block, - displayName: String, - onResumeClick: (String) -> Unit, -) { - OpenEdXButton( - modifier = modifier - .fillMaxWidth() - .defaultMinSize(minHeight = 54.dp), - onClick = { - onResumeClick(block.id) - }, - content = { - Row( - modifier = Modifier.fillMaxWidth(), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.SpaceBetween - ) { - Text( - modifier = Modifier.weight(1f), - text = displayName, - color = MaterialTheme.appColors.primaryButtonText, - style = MaterialTheme.appTypography.titleMedium, - fontWeight = FontWeight.W600 - ) - TextIcon( - text = stringResource(id = R.string.course_continue), - icon = Icons.AutoMirrored.Filled.ArrowForward, - color = MaterialTheme.appColors.primaryButtonText, - textStyle = MaterialTheme.appTypography.labelLarge - ) - } - } - ) -} - fun getUnitBlockIcon(block: Block): Int { return when (block.type) { BlockType.VIDEO -> R.drawable.course_ic_video @@ -406,9 +357,9 @@ private fun CourseOutlineScreenPreview() { CourseContentAllUI( windowSize = WindowSize(WindowType.Compact, WindowType.Compact), uiState = CourseContentAllUIState.CourseData( - mockCourseStructure, + Mock.mockCourseStructure, mapOf(), - mockChapterBlock, + Mock.mockChapterBlock, "Resumed Unit", mapOf(), mapOf(), @@ -442,9 +393,9 @@ private fun CourseContentAllScreenTabletPreview() { CourseContentAllUI( windowSize = WindowSize(WindowType.Medium, WindowType.Medium), uiState = CourseContentAllUIState.CourseData( - mockCourseStructure, + Mock.mockCourseStructure, mapOf(), - mockChapterBlock, + Mock.mockChapterBlock, "Resumed Unit", mapOf(), mapOf(), @@ -475,78 +426,6 @@ private fun CourseContentAllScreenTabletPreview() { @Composable private fun ResumeCoursePreview() { OpenEdXTheme { - ResumeCourse(block = mockChapterBlock, displayName = "Resumed Unit") {} + ResumeCourseButton(block = Mock.mockChapterBlock, displayName = "Resumed Unit") {} } } - -private val mockAssignmentProgress = AssignmentProgress( - assignmentType = "Home", - numPointsEarned = 1f, - numPointsPossible = 3f, - shortLabel = "HM1" -) -private val mockChapterBlock = Block( - id = "id", - blockId = "blockId", - lmsWebUrl = "lmsWebUrl", - legacyWebUrl = "legacyWebUrl", - studentViewUrl = "studentViewUrl", - type = BlockType.CHAPTER, - displayName = "Chapter", - graded = false, - studentViewData = null, - studentViewMultiDevice = false, - blockCounts = BlockCounts(1), - descendants = emptyList(), - descendantsType = BlockType.CHAPTER, - completion = 0.0, - containsGatedContent = false, - assignmentProgress = mockAssignmentProgress, - due = Date(), - offlineDownload = null -) -private val mockSequentialBlock = Block( - id = "id", - blockId = "blockId", - lmsWebUrl = "lmsWebUrl", - legacyWebUrl = "legacyWebUrl", - studentViewUrl = "studentViewUrl", - type = BlockType.SEQUENTIAL, - displayName = "Sequential", - graded = false, - studentViewData = null, - studentViewMultiDevice = false, - blockCounts = BlockCounts(1), - descendants = emptyList(), - descendantsType = BlockType.CHAPTER, - completion = 0.0, - containsGatedContent = false, - assignmentProgress = mockAssignmentProgress, - due = Date(), - offlineDownload = OfflineDownload("fileUrl", "", 1), -) - -private val mockCourseStructure = CourseStructure( - root = "", - blockData = listOf(mockSequentialBlock, mockSequentialBlock), - id = "id", - name = "Course name", - number = "", - org = "Org", - start = Date(), - startDisplay = "", - startType = "", - end = Date(), - coursewareAccess = CoursewareAccess( - true, - "", - "", - "", - "", - "" - ), - media = null, - certificate = null, - isSelfPaced = false, - progress = Progress(1, 3), -) diff --git a/course/src/main/java/org/openedx/course/presentation/outline/CourseContentAllViewModel.kt b/course/src/main/java/org/openedx/course/presentation/outline/CourseContentAllViewModel.kt index 2c966a0cf..d373467a0 100644 --- a/course/src/main/java/org/openedx/course/presentation/outline/CourseContentAllViewModel.kt +++ b/course/src/main/java/org/openedx/course/presentation/outline/CourseContentAllViewModel.kt @@ -28,7 +28,6 @@ import org.openedx.core.module.db.DownloadDao import org.openedx.core.module.download.BaseDownloadViewModel import org.openedx.core.module.download.DownloadHelper import org.openedx.core.presentation.CoreAnalytics -import org.openedx.core.presentation.course.CourseViewMode import org.openedx.core.presentation.dialog.downloaddialog.DownloadDialogManager import org.openedx.core.presentation.settings.calendarsync.CalendarSyncDialogType import org.openedx.core.system.connection.NetworkConnection @@ -42,6 +41,7 @@ import org.openedx.course.presentation.CourseAnalytics import org.openedx.course.presentation.CourseAnalyticsEvent import org.openedx.course.presentation.CourseAnalyticsKey import org.openedx.course.presentation.CourseRouter +import org.openedx.course.presentation.unit.container.CourseViewMode import org.openedx.foundation.extension.isInternetError import org.openedx.foundation.presentation.UIMessage import org.openedx.foundation.system.ResourceManager diff --git a/course/src/main/java/org/openedx/course/presentation/progress/CourseProgressScreen.kt b/course/src/main/java/org/openedx/course/presentation/progress/CourseProgressScreen.kt index 47a01e416..c2954c84a 100644 --- a/course/src/main/java/org/openedx/course/presentation/progress/CourseProgressScreen.kt +++ b/course/src/main/java/org/openedx/course/presentation/progress/CourseProgressScreen.kt @@ -134,7 +134,8 @@ private fun CourseProgressContent( ) } if (gradingPolicy == null) return@LazyColumn - if (gradingPolicy.assignmentPolicies.isNotEmpty()) { + val assignmentPolicies = uiState.progress.getNotEmptyGradingPolicies() + if (!assignmentPolicies.isNullOrEmpty()) { item { OverallGradeView( progress = uiState.progress, @@ -143,9 +144,9 @@ private fun CourseProgressContent( item { GradeDetailsHeaderView() } - itemsIndexed(gradingPolicy.assignmentPolicies) { index, policy -> + itemsIndexed(assignmentPolicies) { index, policy -> AssignmentTypeRow( - progress = uiState.progress, + uiState = uiState, policy = policy, color = if (gradingPolicy.assignmentColors.isNotEmpty()) { gradingPolicy.assignmentColors[index % gradingPolicy.assignmentColors.size] @@ -234,7 +235,7 @@ private fun GradeDetailsHeaderView() { } @Composable -private fun GradeDetailsFooterView( +fun GradeDetailsFooterView( progress: CourseProgress, ) { Row( @@ -276,108 +277,14 @@ private fun OverallGradeView( style = MaterialTheme.appTypography.labelMedium, color = MaterialTheme.appColors.textDark, ) - Text( - text = buildAnnotatedString { - withStyle( - style = SpanStyle( - color = MaterialTheme.appColors.textDark, - fontSize = MaterialTheme.appTypography.labelMedium.fontSize, - fontFamily = MaterialTheme.appTypography.labelMedium.fontFamily, - fontWeight = MaterialTheme.appTypography.labelMedium.fontWeight - ) - ) { - append(stringResource(R.string.course_progress_current_overall) + " ") - } - withStyle( - style = SpanStyle( - color = MaterialTheme.appColors.primary, - fontSize = MaterialTheme.appTypography.labelMedium.fontSize, - fontFamily = MaterialTheme.appTypography.labelMedium.fontFamily, - fontWeight = FontWeight.SemiBold - ) - ) { - append("${progress.getTotalWeightPercent().toInt()}%") - } - }, - style = MaterialTheme.appTypography.labelMedium, - ) - + CurrentOverallGradeText(progress = progress) Column { - Row( - modifier = Modifier - .fillMaxWidth() - .height(8.dp) - .clip(CircleShape) - .border( - width = 1.dp, - color = MaterialTheme.appColors.gradeProgressBarBorder, - shape = CircleShape - ) - ) { - gradingPolicy.assignmentPolicies.forEachIndexed { index, assignmentPolicy -> - val assignmentColors = gradingPolicy.assignmentColors - val color = if (assignmentColors.isNotEmpty()) { - assignmentColors[ - gradingPolicy.assignmentPolicies.indexOf( - assignmentPolicy - ) % assignmentColors.size - ] - } else { - MaterialTheme.appColors.primary - } - val weightedPercent = - progress.getAssignmentWeightedGradedPercent(assignmentPolicy) - if (weightedPercent > 0f) { - Box( - modifier = Modifier - .weight(weightedPercent) - .background(color) - .fillMaxHeight() - ) - - // Add black separator between assignment policies (except after the last one) - if (index < gradingPolicy.assignmentPolicies.size - 1) { - Box( - modifier = Modifier - .width(1.dp) - .background(Color.Black) - .fillMaxHeight() - ) - } - } - } - if (notCompletedWeightedGradePercent > 0f) { - Box( - modifier = Modifier - .weight(notCompletedWeightedGradePercent) - .fillMaxHeight() - ) - } - } - Box( - modifier = Modifier - .fillMaxWidth(progress.requiredGrade), - contentAlignment = Alignment.CenterEnd - ) { - Box( - modifier = Modifier.offset(x = 20.dp), - contentAlignment = Alignment.Center - ) { - Icon( - painter = painterResource(id = R.drawable.ic_course_marker), - tint = MaterialTheme.appColors.warning, - contentDescription = null - ) - Text( - modifier = Modifier - .offset(y = 2.dp) - .clearAndSetSemantics { }, - text = "${progress.requiredGradePercent}%", - style = MaterialTheme.appTypography.labelMedium, - color = MaterialTheme.appColors.textDark, - ) - } - } + GradeProgressBar( + progress = progress, + gradingPolicy = gradingPolicy, + notCompletedWeightedGradePercent = notCompletedWeightedGradePercent + ) + RequiredGradeMarker(progress = progress) } Surface( @@ -434,53 +341,23 @@ private fun CourseCompletionView( color = MaterialTheme.appColors.textDark, ) } - Box( - modifier = Modifier - .align(Alignment.CenterVertically) - .semantics(mergeDescendants = true) {} - ) { - CircularProgressIndicator( - modifier = Modifier - .size(100.dp) - .border( - width = 1.dp, - color = MaterialTheme.appColors.progressBarBackgroundColor, - shape = CircleShape - ) - .padding(3.dp), - progress = progress.completion, - color = MaterialTheme.appColors.primary, - backgroundColor = MaterialTheme.appColors.progressBarBackgroundColor, - strokeWidth = 10.dp, - strokeCap = StrokeCap.Round - ) - Column( - modifier = Modifier.align(Alignment.Center), - horizontalAlignment = Alignment.CenterHorizontally - ) { - Text( - text = "${progress.completionPercent}%", - style = MaterialTheme.appTypography.headlineSmall, - color = MaterialTheme.appColors.primary, - ) - Text( - text = stringResource(R.string.course_completed), - style = MaterialTheme.appTypography.labelSmall, - color = MaterialTheme.appColors.textPrimaryVariant, - ) - } - } + CourseCompletionCircularProgress( + progress = progress.completion, + progressPercent = progress.completionPercent, + completedText = stringResource(R.string.course_completed) + ) } } @Composable private fun AssignmentTypeRow( - progress: CourseProgress, + uiState: CourseProgressUIState.Data, policy: CourseProgress.GradingPolicy.AssignmentPolicy, color: Color ) { - val earned = progress.getEarnedAssignmentProblems(policy) - val possible = progress.getPossibleAssignmentProblems(policy) + val assignments = uiState.progress.getAssignmentSections(policy.type) + val earned = uiState.progress.getCompletedAssignmentCount(policy, uiState.courseStructure) + val possible = assignments.size Column( modifier = Modifier .semantics(mergeDescendants = true) {} @@ -514,8 +391,8 @@ private fun AssignmentTypeRow( Text( text = stringResource( R.string.course_progress_earned_possible_assignment_problems, - earned.toInt(), - possible.toInt() + earned, + possible ), style = MaterialTheme.appTypography.bodySmall, color = MaterialTheme.appColors.textDark, @@ -535,7 +412,7 @@ private fun AssignmentTypeRow( Text( stringResource( R.string.course_progress_current_and_max_weighted_graded_percent, - progress.getAssignmentWeightedGradedPercent(policy).toInt(), + uiState.progress.getAssignmentWeightedGradedPercent(policy).toInt(), (policy.weight * 100).toInt() ), style = MaterialTheme.appTypography.bodyLarge, @@ -545,3 +422,168 @@ private fun AssignmentTypeRow( } } } + +@Composable +fun CourseCompletionCircularProgress( + modifier: Modifier = Modifier, + progress: Float, + progressPercent: Int, + completedText: String +) { + Box( + modifier = modifier + .semantics(mergeDescendants = true) {} + ) { + CircularProgressIndicator( + modifier = Modifier + .size(100.dp) + .border( + width = 1.dp, + color = MaterialTheme.appColors.progressBarBackgroundColor, + shape = CircleShape + ) + .padding(3.dp), + progress = progress, + color = MaterialTheme.appColors.primary, + backgroundColor = MaterialTheme.appColors.progressBarBackgroundColor, + strokeWidth = 10.dp, + strokeCap = StrokeCap.Round + ) + Column( + modifier = Modifier.align(Alignment.Center), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Text( + text = "$progressPercent%", + style = MaterialTheme.appTypography.headlineSmall, + color = MaterialTheme.appColors.primary, + ) + Text( + text = completedText, + style = MaterialTheme.appTypography.labelSmall, + color = MaterialTheme.appColors.textPrimaryVariant, + ) + } + } +} + +@Composable +fun GradeProgressBar( + progress: CourseProgress, + gradingPolicy: CourseProgress.GradingPolicy, + notCompletedWeightedGradePercent: Float +) { + Row( + modifier = Modifier + .fillMaxWidth() + .height(8.dp) + .clip(CircleShape) + .border( + width = 1.dp, + color = MaterialTheme.appColors.gradeProgressBarBorder, + shape = CircleShape + ) + ) { + gradingPolicy.assignmentPolicies.forEachIndexed { index, assignmentPolicy -> + val assignmentColors = gradingPolicy.assignmentColors + val color = if (assignmentColors.isNotEmpty()) { + assignmentColors[ + gradingPolicy.assignmentPolicies.indexOf( + assignmentPolicy + ) % assignmentColors.size + ] + } else { + MaterialTheme.appColors.primary + } + val weightedPercent = + progress.getAssignmentWeightedGradedPercent(assignmentPolicy) + if (weightedPercent > 0f) { + Box( + modifier = Modifier + .weight(weightedPercent) + .background(color) + .fillMaxHeight() + ) + + // Add black separator between assignment policies (except after the last one) + if (index < gradingPolicy.assignmentPolicies.size - 1) { + Box( + modifier = Modifier + .width(1.dp) + .background(Color.Black) + .fillMaxHeight() + ) + } + } + } + if (notCompletedWeightedGradePercent > 0f) { + Box( + modifier = Modifier + .weight(notCompletedWeightedGradePercent) + .background(MaterialTheme.appColors.gradeProgressBarBackground) + .fillMaxHeight() + ) + } + } +} + +@Composable +fun RequiredGradeMarker( + progress: CourseProgress +) { + Box( + modifier = Modifier + .fillMaxWidth(progress.requiredGrade), + contentAlignment = Alignment.CenterEnd + ) { + Box( + modifier = Modifier.offset(x = 20.dp), + contentAlignment = Alignment.Center + ) { + Icon( + painter = painterResource(id = R.drawable.ic_course_marker), + tint = MaterialTheme.appColors.warning, + contentDescription = null + ) + Text( + modifier = Modifier + .offset(y = 2.dp) + .clearAndSetSemantics { }, + text = "${progress.requiredGradePercent}%", + style = MaterialTheme.appTypography.labelMedium, + color = MaterialTheme.appColors.textDark, + ) + } + } +} + +@Composable +fun CurrentOverallGradeText( + progress: CourseProgress +) { + Text( + text = buildAnnotatedString { + withStyle( + style = SpanStyle( + color = MaterialTheme.appColors.textDark, + fontSize = MaterialTheme.appTypography.labelMedium.fontSize, + fontFamily = MaterialTheme.appTypography.labelMedium.fontFamily, + fontWeight = MaterialTheme.appTypography.labelMedium.fontWeight + ) + ) { + append(stringResource(R.string.course_progress_current_overall) + " ") + } + withStyle( + style = SpanStyle( + color = MaterialTheme.appColors.primary, + fontSize = MaterialTheme.appTypography.labelMedium.fontSize, + fontFamily = MaterialTheme.appTypography.labelMedium.fontFamily, + fontWeight = FontWeight.SemiBold + ) + ) { + append("${progress.getTotalWeightPercent().toInt()}%") + } + }, + style = MaterialTheme.appTypography.labelMedium, + ) +} diff --git a/course/src/main/java/org/openedx/course/presentation/progress/CourseProgressUIState.kt b/course/src/main/java/org/openedx/course/presentation/progress/CourseProgressUIState.kt index 25771f631..ce504ce39 100644 --- a/course/src/main/java/org/openedx/course/presentation/progress/CourseProgressUIState.kt +++ b/course/src/main/java/org/openedx/course/presentation/progress/CourseProgressUIState.kt @@ -1,9 +1,13 @@ package org.openedx.course.presentation.progress import org.openedx.core.domain.model.CourseProgress +import org.openedx.core.domain.model.CourseStructure sealed class CourseProgressUIState { data object Error : CourseProgressUIState() data object Loading : CourseProgressUIState() - data class Data(val progress: CourseProgress) : CourseProgressUIState() + data class Data( + val progress: CourseProgress, + val courseStructure: CourseStructure?, + ) : CourseProgressUIState() } diff --git a/course/src/main/java/org/openedx/course/presentation/progress/CourseProgressViewModel.kt b/course/src/main/java/org/openedx/course/presentation/progress/CourseProgressViewModel.kt index c5c4b1f06..805f486d1 100644 --- a/course/src/main/java/org/openedx/course/presentation/progress/CourseProgressViewModel.kt +++ b/course/src/main/java/org/openedx/course/presentation/progress/CourseProgressViewModel.kt @@ -1,7 +1,6 @@ package org.openedx.course.presentation.progress import androidx.lifecycle.viewModelScope -import kotlinx.coroutines.Job import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharedFlow @@ -9,10 +8,11 @@ import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.catch -import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.flow.combine import kotlinx.coroutines.launch import org.openedx.core.system.notifier.CourseLoading import org.openedx.core.system.notifier.CourseNotifier +import org.openedx.core.system.notifier.CourseProgressLoaded import org.openedx.core.system.notifier.CourseStructureUpdated import org.openedx.core.system.notifier.RefreshProgress import org.openedx.course.domain.interactor.CourseInteractor @@ -33,30 +33,34 @@ class CourseProgressViewModel( val uiMessage: SharedFlow get() = _uiMessage.asSharedFlow() - private var progressJob: Job? = null - init { - loadCourseProgress(false) + collectData(false) collectCourseNotifier() } - fun loadCourseProgress(isRefresh: Boolean) { - progressJob?.cancel() - progressJob = viewModelScope.launch { - if (!isRefresh) { - _uiState.value = CourseProgressUIState.Loading - } - interactor.getCourseProgress(courseId, isRefresh) - .catch { e -> - if (_uiState.value !is CourseProgressUIState.Data) { - _uiState.value = CourseProgressUIState.Error - } - courseNotifier.send(CourseLoading(false)) - } - .collectLatest { progress -> - _uiState.value = CourseProgressUIState.Data(progress) - courseNotifier.send(CourseLoading(false)) + private fun collectData(isRefresh: Boolean) { + viewModelScope.launch { + val courseProgressFlow = interactor.getCourseProgress(courseId, isRefresh, false) + val courseStructureFlow = interactor.getCourseStructureFlow(courseId) + + combine( + courseProgressFlow, + courseStructureFlow + ) { courseProgress, courseStructure -> + courseProgress to courseStructure + }.catch { e -> + if (_uiState.value !is CourseProgressUIState.Data) { + _uiState.value = CourseProgressUIState.Error } + courseNotifier.send(CourseLoading(false)) + }.collect { (courseProgress, courseStructure) -> + _uiState.value = CourseProgressUIState.Data( + courseProgress, + courseStructure + ) + courseNotifier.send(CourseLoading(false)) + courseNotifier.send(CourseProgressLoaded) + } } } @@ -64,7 +68,7 @@ class CourseProgressViewModel( viewModelScope.launch { courseNotifier.notifier.collect { event -> when (event) { - is RefreshProgress, is CourseStructureUpdated -> loadCourseProgress(true) + is RefreshProgress, is CourseStructureUpdated -> collectData(true) } } } diff --git a/course/src/main/java/org/openedx/course/presentation/section/CourseSectionFragment.kt b/course/src/main/java/org/openedx/course/presentation/section/CourseSectionFragment.kt index 0fb24ebd6..36e20ce2c 100644 --- a/course/src/main/java/org/openedx/course/presentation/section/CourseSectionFragment.kt +++ b/course/src/main/java/org/openedx/course/presentation/section/CourseSectionFragment.kt @@ -58,7 +58,6 @@ import org.openedx.core.BlockType import org.openedx.core.domain.model.AssignmentProgress import org.openedx.core.domain.model.Block import org.openedx.core.domain.model.BlockCounts -import org.openedx.core.presentation.course.CourseViewMode import org.openedx.core.ui.BackBtn import org.openedx.core.ui.HandleUIMessage import org.openedx.core.ui.displayCutoutForLandscape @@ -70,6 +69,7 @@ import org.openedx.core.ui.theme.appTypography import org.openedx.course.R import org.openedx.course.presentation.CourseRouter import org.openedx.course.presentation.ui.CardArrow +import org.openedx.course.presentation.unit.container.CourseViewMode import org.openedx.foundation.extension.serializable import org.openedx.foundation.presentation.UIMessage import org.openedx.foundation.presentation.WindowSize diff --git a/course/src/main/java/org/openedx/course/presentation/section/CourseSectionViewModel.kt b/course/src/main/java/org/openedx/course/presentation/section/CourseSectionViewModel.kt index 2ebe2c9b3..8966ee45e 100644 --- a/course/src/main/java/org/openedx/course/presentation/section/CourseSectionViewModel.kt +++ b/course/src/main/java/org/openedx/course/presentation/section/CourseSectionViewModel.kt @@ -8,13 +8,13 @@ import kotlinx.coroutines.launch import org.openedx.core.BlockType import org.openedx.core.R import org.openedx.core.domain.model.Block -import org.openedx.core.presentation.course.CourseViewMode import org.openedx.core.system.notifier.CourseNotifier import org.openedx.core.system.notifier.CourseSectionChanged import org.openedx.course.domain.interactor.CourseInteractor import org.openedx.course.presentation.CourseAnalytics import org.openedx.course.presentation.CourseAnalyticsEvent import org.openedx.course.presentation.CourseAnalyticsKey +import org.openedx.course.presentation.unit.container.CourseViewMode import org.openedx.foundation.extension.isInternetError import org.openedx.foundation.presentation.BaseViewModel import org.openedx.foundation.presentation.SingleEventLiveData diff --git a/course/src/main/java/org/openedx/course/presentation/ui/CourseUI.kt b/course/src/main/java/org/openedx/course/presentation/ui/CourseUI.kt index 54075d183..82c28cb4f 100644 --- a/course/src/main/java/org/openedx/course/presentation/ui/CourseUI.kt +++ b/course/src/main/java/org/openedx/course/presentation/ui/CourseUI.kt @@ -17,6 +17,7 @@ import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.defaultMinSize import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth @@ -50,6 +51,7 @@ import androidx.compose.material.Snackbar import androidx.compose.material.Surface import androidx.compose.material.Text import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowForward import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight import androidx.compose.material.icons.filled.Close import androidx.compose.material.icons.filled.CloudDone @@ -77,6 +79,7 @@ import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextDecoration @@ -103,6 +106,7 @@ import org.openedx.core.ui.BackBtn import org.openedx.core.ui.IconText import org.openedx.core.ui.OpenEdXButton import org.openedx.core.ui.OpenEdXOutlinedButton +import org.openedx.core.ui.TextIcon import org.openedx.core.ui.displayCutoutForLandscape import org.openedx.core.ui.noRippleClickable import org.openedx.core.ui.theme.OpenEdXTheme @@ -614,7 +618,7 @@ fun CourseVideoSection( block: Block, videoBlocks: List, preview: Map, - progress: Map, + progress: Map, downloadedStateMap: Map, onVideoClick: (Block) -> Unit, onDownloadClick: (blocksIds: List) -> Unit, @@ -628,6 +632,8 @@ fun CourseVideoSection( filteredStatuses.any { it.isWaitingOrDownloading } -> DownloadedState.DOWNLOADING else -> DownloadedState.NOT_DOWNLOADED } + val videoCardWidth = 192.dp + val rowHorizontalArrangement = 8.dp LaunchedEffect(Unit) { try { @@ -651,19 +657,29 @@ fun CourseVideoSection( ) LazyRow( state = state, - horizontalArrangement = Arrangement.spacedBy(8.dp), + horizontalArrangement = Arrangement.spacedBy(rowHorizontalArrangement), contentPadding = PaddingValues( top = 8.dp, bottom = 16.dp, start = 16.dp, - end = 16.dp, + end = videoCardWidth + rowHorizontalArrangement, ) ) { items(videoBlocks) { block -> + val localProgress = progress[block.id] + val progress = localProgress ?: if (block.isCompleted()) { + 1f + } else { + 0f + } CourseVideoItem( + modifier = Modifier + .width(videoCardWidth) + .height(108.dp) + .clip(MaterialTheme.appShapes.videoPreviewShape), videoBlock = block, preview = preview[block.id], - progress = progress[block.id] ?: 0f, + progress = progress, onClick = { onVideoClick(block) } @@ -676,16 +692,17 @@ fun CourseVideoSection( @Composable fun CourseVideoItem( + modifier: Modifier = Modifier, videoBlock: Block, preview: VideoPreview?, progress: Float, - onClick: () -> Unit + onClick: () -> Unit, + titleStyle: TextStyle = MaterialTheme.appTypography.bodySmall, + contentModifier: Modifier = Modifier.padding(8.dp), + progressModifier: Modifier = Modifier.height(4.dp), ) { Box( - modifier = Modifier - .width(192.dp) - .height(108.dp) - .clip(MaterialTheme.appShapes.videoPreviewShape) + modifier = modifier .let { if (videoBlock.isCompleted()) { it.border( @@ -726,58 +743,65 @@ fun CourseVideoItem( ) ) - Image( - modifier = Modifier - .size(32.dp) - .align(Alignment.Center), - painter = painterResource(id = R.drawable.course_video_play_button), - contentDescription = null, - ) + Box( + modifier = contentModifier.fillMaxSize() + ) { + Image( + modifier = Modifier + .size(32.dp) + .align(Alignment.Center), + painter = painterResource(id = R.drawable.course_video_play_button), + contentDescription = null, + ) - // Title (top-left) - Text( - text = videoBlock.displayName, - color = Color.White, - style = MaterialTheme.appTypography.bodySmall, - modifier = Modifier - .align(Alignment.TopStart) - .padding(8.dp), - maxLines = 2, - overflow = TextOverflow.Ellipsis - ) + // Title (top-left) + Text( + text = videoBlock.displayName, + color = Color.White, + style = titleStyle, + modifier = Modifier + .align(Alignment.TopStart), + maxLines = 2, + overflow = TextOverflow.Ellipsis + ) - // Progress bar (bottom) - if (progress > 0.0f) { + // Progress bar (bottom) Box( modifier = Modifier - .padding(bottom = 4.dp) - .height(16.dp) + .fillMaxWidth() .align(Alignment.BottomCenter), contentAlignment = Alignment.Center ) { - LinearProgressIndicator( - modifier = Modifier - .fillMaxWidth() - .height(4.dp) - .padding(horizontal = 8.dp) - .clip(CircleShape), - progress = progress, - color = if (videoBlock.isCompleted() && progress > 0.95f) { - MaterialTheme.appColors.progressBarColor - } else { - MaterialTheme.appColors.primary - }, - backgroundColor = MaterialTheme.appColors.progressBarBackgroundColor - ) + if (progress > 0.0f) { + LinearProgressIndicator( + modifier = progressModifier + .fillMaxWidth() + .clip(CircleShape), + progress = progress, + color = if (videoBlock.isCompleted() && progress > 0.95f) { + MaterialTheme.appColors.progressBarColor + } else { + MaterialTheme.appColors.info + }, + backgroundColor = MaterialTheme.appColors.progressBarBackgroundColor + ) + } if (videoBlock.isCompleted()) { Image( modifier = Modifier .align(Alignment.BottomEnd) .size(16.dp) - .offset(x = (-4).dp), + .offset(x = 1.dp), painter = painterResource(id = coreR.drawable.ic_core_check), contentDescription = stringResource(R.string.course_accessibility_video_watched), ) + } else { + Box( + modifier = Modifier + .align(Alignment.BottomEnd) + .size(16.dp) + .offset(x = 1.dp), + ) } } } @@ -850,7 +874,7 @@ fun DownloadIcon( val downloadIconTint = if (downloadedState == DownloadedState.DOWNLOADED) { MaterialTheme.appColors.successGreen } else { - MaterialTheme.appColors.textAccent + MaterialTheme.appColors.primary } IconButton( modifier = iconModifier, @@ -900,14 +924,18 @@ fun DownloadIcon( @Composable fun CourseSection( modifier: Modifier = Modifier, - block: Block, + section: Block, useRelativeDates: Boolean, + showDueDate: Boolean = true, + isExpandable: Boolean = true, onItemClick: (Block) -> Unit, isSectionVisible: Boolean?, - courseSubSections: List?, + subSections: List?, downloadedStateMap: Map, onSubSectionClick: (Block) -> Unit, onDownloadClick: (blocksIds: List) -> Unit, + progress: Float? = null, + background: Color = MaterialTheme.appColors.cardViewBackground ) { val arrowRotation by animateFloatAsState( targetValue = if (isSectionVisible == true) { @@ -917,7 +945,7 @@ fun CourseSection( }, label = "" ) - val subSectionIds = courseSubSections?.map { it.id }.orEmpty() + val subSectionIds = subSections?.map { it.id }.orEmpty() val filteredStatuses = downloadedStateMap.filterKeys { it in subSectionIds }.values val downloadedState = when { filteredStatuses.isEmpty() -> null @@ -927,15 +955,15 @@ fun CourseSection( } // Section progress - val completedCount = courseSubSections?.count { it.isCompleted() } ?: 0 - val totalCount = courseSubSections?.size ?: 0 - val progress = if (totalCount > 0) completedCount.toFloat() / totalCount else 0f + val completedCount = subSections?.count { it.isCompleted() } ?: 0 + val totalCount = subSections?.size ?: 0 + val progress = progress ?: if (totalCount > 0) completedCount.toFloat() / totalCount else 0f Column( modifier = modifier .clip(MaterialTheme.appShapes.sectionCardShape) - .noRippleClickable { onItemClick(block) } - .background(MaterialTheme.appColors.cardViewBackground) + .noRippleClickable { onItemClick(section) } + .background(background) .border( 1.dp, MaterialTheme.appColors.cardViewBorder, @@ -951,20 +979,22 @@ fun CourseSection( backgroundColor = MaterialTheme.appColors.progressBarBackgroundColor ) CourseExpandableChapterCard( - block = block, + block = section, arrowDegrees = arrowRotation, + isExpandable = isExpandable, downloadedState = downloadedState, onDownloadClick = { - onDownloadClick(block.descendants) + onDownloadClick(section.descendants) } ) - courseSubSections?.forEach { subSectionBlock -> + subSections?.forEach { subSectionBlock -> AnimatedVisibility( visible = isSectionVisible == true ) { CourseSubSectionItem( block = subSectionBlock, onClick = onSubSectionClick, + showDueDate = showDueDate, useRelativeDates = useRelativeDates ) } @@ -977,6 +1007,7 @@ fun CourseExpandableChapterCard( modifier: Modifier = Modifier, block: Block, arrowDegrees: Float = 0f, + isExpandable: Boolean = true, downloadedState: DownloadedState?, onDownloadClick: () -> Unit, ) { @@ -989,7 +1020,9 @@ fun CourseExpandableChapterCard( verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(4.dp) ) { - CardArrow(degrees = arrowDegrees) + if (isExpandable) { + CardArrow(degrees = arrowDegrees) + } if (block.isCompleted()) { val completedIconPainter = painterResource(R.drawable.course_ic_task_alt) val completedIconColor = MaterialTheme.appColors.successGreen @@ -1005,7 +1038,7 @@ fun CourseExpandableChapterCard( Text( modifier = Modifier.weight(1f), text = block.displayName, - style = MaterialTheme.appTypography.titleSmall, + style = MaterialTheme.appTypography.titleMedium, color = MaterialTheme.appColors.textPrimary, maxLines = 1, overflow = TextOverflow.Ellipsis @@ -1022,6 +1055,7 @@ fun CourseSubSectionItem( modifier: Modifier = Modifier, block: Block, useRelativeDates: Boolean, + showDueDate: Boolean, onClick: (Block) -> Unit, ) { val context = LocalContext.current @@ -1065,7 +1099,7 @@ fun CourseSubSectionItem( maxLines = 1 ) Spacer(modifier = Modifier.width(16.dp)) - if (due != null) { + if (due != null || showDueDate) { Icon( imageVector = Icons.AutoMirrored.Filled.KeyboardArrowRight, tint = MaterialTheme.appColors.onSurface, @@ -1093,7 +1127,7 @@ fun CourseSubSectionItem( .filter { !it.isNullOrEmpty() } .joinToString(" - ") - if (assignmentString.isNotEmpty()) { + if (assignmentString.isNotEmpty() && showDueDate) { Spacer(modifier = Modifier.height(8.dp)) Text( text = assignmentString, @@ -1538,6 +1572,44 @@ fun CourseProgress( } } +@Composable +fun ResumeCourseButton( + modifier: Modifier = Modifier, + block: Block, + displayName: String, + onResumeClick: (String) -> Unit, +) { + OpenEdXButton( + modifier = modifier + .fillMaxWidth() + .defaultMinSize(minHeight = 54.dp), + onClick = { + onResumeClick(block.id) + }, + content = { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween + ) { + Text( + modifier = Modifier.weight(1f), + text = displayName, + color = MaterialTheme.appColors.primaryButtonText, + style = MaterialTheme.appTypography.titleMedium, + fontWeight = FontWeight.W600 + ) + TextIcon( + text = stringResource(id = R.string.course_continue), + icon = Icons.AutoMirrored.Filled.ArrowForward, + color = MaterialTheme.appColors.primaryButtonText, + textStyle = MaterialTheme.appTypography.labelLarge + ) + } + } + ) +} + @Preview(uiMode = Configuration.UI_MODE_NIGHT_NO) @Preview(uiMode = Configuration.UI_MODE_NIGHT_YES) @Composable diff --git a/course/src/main/java/org/openedx/course/presentation/unit/container/CourseUnitContainerFragment.kt b/course/src/main/java/org/openedx/course/presentation/unit/container/CourseUnitContainerFragment.kt index 1281084ce..4c4a136ec 100644 --- a/course/src/main/java/org/openedx/course/presentation/unit/container/CourseUnitContainerFragment.kt +++ b/course/src/main/java/org/openedx/course/presentation/unit/container/CourseUnitContainerFragment.kt @@ -31,7 +31,6 @@ import org.koin.android.ext.android.inject import org.koin.androidx.viewmodel.ext.android.viewModel import org.koin.core.parameter.parametersOf import org.openedx.core.BlockType -import org.openedx.core.presentation.course.CourseViewMode import org.openedx.core.presentation.global.InsetHolder import org.openedx.core.ui.theme.OpenEdXTheme import org.openedx.core.ui.theme.appColors diff --git a/course/src/main/java/org/openedx/course/presentation/unit/container/CourseUnitContainerViewModel.kt b/course/src/main/java/org/openedx/course/presentation/unit/container/CourseUnitContainerViewModel.kt index 353a1b0ff..596102dd9 100644 --- a/course/src/main/java/org/openedx/course/presentation/unit/container/CourseUnitContainerViewModel.kt +++ b/course/src/main/java/org/openedx/course/presentation/unit/container/CourseUnitContainerViewModel.kt @@ -14,7 +14,6 @@ import org.openedx.core.config.Config import org.openedx.core.domain.model.Block import org.openedx.core.module.db.DownloadModel import org.openedx.core.module.db.DownloadedState -import org.openedx.core.presentation.course.CourseViewMode import org.openedx.core.system.connection.NetworkConnection import org.openedx.core.system.notifier.CourseNotifier import org.openedx.core.system.notifier.CourseSectionChanged diff --git a/course/src/main/java/org/openedx/course/presentation/unit/container/CourseViewMode.kt b/course/src/main/java/org/openedx/course/presentation/unit/container/CourseViewMode.kt new file mode 100644 index 000000000..1fcace78b --- /dev/null +++ b/course/src/main/java/org/openedx/course/presentation/unit/container/CourseViewMode.kt @@ -0,0 +1,6 @@ +package org.openedx.course.presentation.unit.container + +enum class CourseViewMode { + FULL, + VIDEOS +} diff --git a/course/src/main/java/org/openedx/course/presentation/unit/video/EncodedVideoUnitViewModel.kt b/course/src/main/java/org/openedx/course/presentation/unit/video/EncodedVideoUnitViewModel.kt index 76ded08a9..2c2816bc9 100644 --- a/course/src/main/java/org/openedx/course/presentation/unit/video/EncodedVideoUnitViewModel.kt +++ b/course/src/main/java/org/openedx/course/presentation/unit/video/EncodedVideoUnitViewModel.kt @@ -5,6 +5,7 @@ import android.content.Context import androidx.lifecycle.LifecycleOwner import androidx.lifecycle.LiveData import androidx.lifecycle.MutableLiveData +import androidx.lifecycle.viewModelScope import androidx.media3.cast.CastPlayer import androidx.media3.common.PlaybackParameters import androidx.media3.common.Player @@ -19,6 +20,8 @@ import androidx.media3.exoplayer.trackselection.DefaultTrackSelector import androidx.media3.exoplayer.upstream.DefaultBandwidthMeter import androidx.media3.extractor.DefaultExtractorsFactory import com.google.android.gms.cast.framework.CastContext +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch import org.openedx.core.data.storage.CorePreferences import org.openedx.core.domain.model.VideoQuality import org.openedx.core.module.TranscriptManager @@ -69,8 +72,13 @@ class EncodedVideoUnitViewModel( private val exoPlayerListener = object : Player.Listener { override fun onRenderedFirstFrame() { - duration = exoPlayer?.duration ?: 0L super.onRenderedFirstFrame() + viewModelScope.launch { + while (exoPlayer?.duration == null || exoPlayer?.duration!! < 0f) { + delay(500) + } + duration = exoPlayer?.duration ?: 0L + } } override fun onPlayWhenReadyChanged(playWhenReady: Boolean, reason: Int) { diff --git a/course/src/main/java/org/openedx/course/presentation/videos/CourseContentVideoScreen.kt b/course/src/main/java/org/openedx/course/presentation/videos/CourseContentVideoScreen.kt index 571fde683..f482596ec 100644 --- a/course/src/main/java/org/openedx/course/presentation/videos/CourseContentVideoScreen.kt +++ b/course/src/main/java/org/openedx/course/presentation/videos/CourseContentVideoScreen.kt @@ -9,6 +9,8 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll import androidx.compose.material.Divider import androidx.compose.material.MaterialTheme import androidx.compose.material.Scaffold @@ -35,7 +37,6 @@ import org.openedx.core.domain.model.CourseStructure import org.openedx.core.domain.model.CoursewareAccess import org.openedx.core.domain.model.Progress import org.openedx.core.module.download.DownloadModelsSize -import org.openedx.core.presentation.course.CourseViewMode import org.openedx.core.ui.CircularProgress import org.openedx.core.ui.HandleUIMessage import org.openedx.core.ui.displayCutoutForLandscape @@ -45,6 +46,7 @@ import org.openedx.course.R import org.openedx.course.presentation.contenttab.CourseContentVideoEmptyState import org.openedx.course.presentation.ui.CourseProgress import org.openedx.course.presentation.ui.CourseVideoSection +import org.openedx.course.presentation.unit.container.CourseViewMode import org.openedx.foundation.presentation.UIMessage import org.openedx.foundation.presentation.WindowSize import org.openedx.foundation.presentation.WindowType @@ -142,6 +144,7 @@ private fun CourseVideosUI( when (uiState) { is CourseVideoUIState.Empty -> { CourseContentVideoEmptyState( + modifier = Modifier.verticalScroll(rememberScrollState()), onReturnToCourseClick = onNavigateToHome ) } @@ -179,7 +182,7 @@ private fun CourseVideosUI( null }, description = stringResource( - R.string.course_completed, + R.string.course_completed_of, progress.completed, progress.total ) diff --git a/course/src/main/java/org/openedx/course/presentation/videos/CourseVideoUIState.kt b/course/src/main/java/org/openedx/course/presentation/videos/CourseVideoUIState.kt index 61f1c9283..584814c15 100644 --- a/course/src/main/java/org/openedx/course/presentation/videos/CourseVideoUIState.kt +++ b/course/src/main/java/org/openedx/course/presentation/videos/CourseVideoUIState.kt @@ -15,7 +15,7 @@ sealed class CourseVideoUIState { val downloadModelsSize: DownloadModelsSize, val isCompletedSectionsShown: Boolean, val videoPreview: Map, - val videoProgress: Map, + val videoProgress: Map, ) : CourseVideoUIState() data object Empty : CourseVideoUIState() diff --git a/course/src/main/java/org/openedx/course/presentation/videos/CourseVideoViewModel.kt b/course/src/main/java/org/openedx/course/presentation/videos/CourseVideoViewModel.kt index c37b8709e..5dffd7688 100644 --- a/course/src/main/java/org/openedx/course/presentation/videos/CourseVideoViewModel.kt +++ b/course/src/main/java/org/openedx/course/presentation/videos/CourseVideoViewModel.kt @@ -12,7 +12,6 @@ import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext import org.openedx.core.BlockType import org.openedx.core.config.Config import org.openedx.core.data.storage.CorePreferences @@ -148,20 +147,15 @@ class CourseVideoViewModel( courseSubSectionUnit.clear() courseStructure = courseStructure.copy(blockData = sortBlocks(blocks)) initDownloadModelsStatus() - val downloadingModels = getDownloadModelList() - val videoPreview = withContext(Dispatchers.IO) { - courseVideos.values.flatten().associate { block -> - block.id to block.getVideoPreview( - context, - networkConnection.isOnline(), - downloadingModels.find { block.id == it.id }?.path - ) - } - } val videoProgress = courseVideos.values.flatten().associate { block -> val videoProgressEntity = interactor.getVideoProgress(block.id) - val progress = videoProgressEntity.videoTime.toFloat() - .safeDivBy(videoProgressEntity.duration.toFloat()) + val videoTime = videoProgressEntity.videoTime?.toFloat() + val videoDuration = videoProgressEntity.duration?.toFloat() + val progress = if (videoTime != null && videoDuration != null) { + videoTime.safeDivBy(videoDuration) + } else { + null + } block.id to progress } val isCompletedSectionsShown = @@ -176,11 +170,13 @@ class CourseVideoViewModel( subSectionsDownloadsCount = subSectionsDownloadsCount, downloadModelsSize = getDownloadModelsSize(), isCompletedSectionsShown = isCompletedSectionsShown, - videoPreview = videoPreview, + videoPreview = (_uiState.value as? CourseVideoUIState.CourseData)?.videoPreview + ?: emptyMap(), videoProgress = videoProgress, ) } courseNotifier.send(CourseLoading(false)) + getVideoPreviews() } catch (e: Exception) { e.printStackTrace() _uiState.value = CourseVideoUIState.Empty @@ -188,6 +184,24 @@ class CourseVideoViewModel( } } + private fun getVideoPreviews() { + viewModelScope.launch(Dispatchers.IO) { + val downloadingModels = getDownloadModelList() + courseVideos.values.flatten().forEach { block -> + val previewMap = block.id to block.getVideoPreview( + context, + networkConnection.isOnline(), + downloadingModels.find { block.id == it.id }?.path + ) + val currentUiState = + (_uiState.value as? CourseVideoUIState.CourseData) ?: return@forEach + _uiState.value = currentUiState.copy( + videoPreview = currentUiState.videoPreview + previewMap + ) + } + } + } + private fun sortBlocks(blocks: List): List { if (blocks.isEmpty()) return emptyList() diff --git a/course/src/main/res/values/strings.xml b/course/src/main/res/values/strings.xml index 33852c242..dd59fcf8f 100644 --- a/course/src/main/res/values/strings.xml +++ b/course/src/main/res/values/strings.xml @@ -45,6 +45,14 @@ Videos Assignments + + Course Completion + Progress + You have completed %1$s%% of the course content + Completed + Next Sequence + View All Content + Video player Remove course section @@ -77,8 +85,33 @@ %1$s/%2$s Completed Complete - %1$s points Past Due - %1$s points - In Progress - %1$s points + - %1$s points %1$s %% of Grade Review Course Grading Policy Return to Course Home + + + Continue Watching + Next Video + View All Videos + %1$s left + Videos\ncompleted + + + Assignments\ncompleted + View All Assignments + Next Assignment + Due Today: %1$s + %1$d Days Past Due: %2$s + Due in %1$d Days: %2$s + Section: %1$s + + + Grades + This represents your weighted grade against the grade needed to pass this course. + View Progress + + + You\'re all caught up on videos! + You\'re all caught up on assignments! diff --git a/course/src/test/java/org/openedx/course/presentation/home/CourseHomeViewModelTest.kt b/course/src/test/java/org/openedx/course/presentation/home/CourseHomeViewModelTest.kt new file mode 100644 index 000000000..cbd0d04f0 --- /dev/null +++ b/course/src/test/java/org/openedx/course/presentation/home/CourseHomeViewModelTest.kt @@ -0,0 +1,863 @@ +package org.openedx.course.presentation.home + +import android.content.Context +import androidx.arch.core.executor.testing.InstantTaskExecutorRule +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.every +import io.mockk.mockk +import io.mockk.verify +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TestRule +import org.openedx.core.Mock +import org.openedx.core.R +import org.openedx.core.config.Config +import org.openedx.core.data.storage.CorePreferences +import org.openedx.core.module.DownloadWorkerController +import org.openedx.core.module.db.DownloadDao +import org.openedx.core.module.download.DownloadHelper +import org.openedx.core.presentation.CoreAnalytics +import org.openedx.core.presentation.dialog.downloaddialog.DownloadDialogManager +import org.openedx.core.system.connection.NetworkConnection +import org.openedx.core.system.notifier.CourseDatesShifted +import org.openedx.core.system.notifier.CourseNotifier +import org.openedx.core.system.notifier.CourseOpenBlock +import org.openedx.core.system.notifier.CourseProgressLoaded +import org.openedx.core.system.notifier.CourseStructureUpdated +import org.openedx.course.domain.interactor.CourseInteractor +import org.openedx.course.presentation.CourseAnalytics +import org.openedx.course.presentation.CourseAnalyticsEvent +import org.openedx.course.presentation.CourseAnalyticsKey +import org.openedx.course.presentation.CourseRouter +import org.openedx.foundation.system.ResourceManager +import org.openedx.foundation.utils.FileUtil +import java.net.UnknownHostException +import org.openedx.course.R as courseR + +@Suppress("LargeClass") +@OptIn(ExperimentalCoroutinesApi::class) +class CourseHomeViewModelTest { + + @get:Rule + val testInstantTaskExecutorRule: TestRule = InstantTaskExecutorRule() + private val dispatcher = StandardTestDispatcher() + private val courseId = "test-course-id" + private val courseTitle = "Test Course" + private val context = mockk() + private val config = mockk() + private val interactor = mockk() + private val resourceManager = mockk() + private val courseNotifier = mockk() + private val networkConnection = mockk() + private val preferencesManager = mockk() + private val analytics = mockk() + private val downloadDialogManager = mockk() + private val fileUtil = mockk() + private val courseRouter = mockk() + private val coreAnalytics = mockk() + private val downloadDao = mockk() + private val workerController = mockk() + private val downloadHelper = mockk() + + private val noInternet = "Slow or no internet connection" + private val somethingWrong = "Something went wrong" + private val cantDownload = "You can download content only from Wi-fi" + + private val courseStructure = Mock.mockCourseStructure.copy( + id = courseId, + name = courseTitle + ) + private val courseComponentStatus = Mock.mockCourseComponentStatus + private val courseDatesResult = Mock.mockCourseDatesResult + private val courseProgress = Mock.mockCourseProgress + private val videoProgress = Mock.mockVideoProgress + private val resetCourseDates = Mock.mockResetCourseDates + + @Before + fun setUp() { + Dispatchers.setMain(dispatcher) + + every { resourceManager.getString(R.string.core_error_no_connection) } returns noInternet + every { resourceManager.getString(R.string.core_error_unknown_error) } returns somethingWrong + every { + resourceManager.getString(courseR.string.course_can_download_only_with_wifi) + } returns cantDownload + every { + resourceManager.getString(R.string.core_dates_shift_dates_unsuccessful_msg) + } returns "Failed to shift dates" + + every { config.getCourseUIConfig().isCourseDropdownNavigationEnabled } returns true + every { config.getCourseUIConfig().isCourseDownloadQueueEnabled } returns true + + every { preferencesManager.isRelativeDatesEnabled } returns true + every { preferencesManager.videoSettings.wifiDownloadOnly } returns false + + every { networkConnection.isWifiConnected() } returns true + every { networkConnection.isOnline() } returns true + + every { fileUtil.getExternalAppDir().path } returns "/test/path" + + every { downloadDao.getAllDataFlow() } returns flow { emit(emptyList()) } + + every { courseNotifier.notifier } returns flow { } + coEvery { courseNotifier.send(any()) } returns Unit + + every { analytics.logEvent(any(), any()) } returns Unit + every { coreAnalytics.logEvent(any(), any()) } returns Unit + + every { + downloadDialogManager.showPopup( + any(), + any(), + any(), + any(), + any(), + any() + ) + } returns Unit + + coEvery { workerController.saveModels(any()) } returns Unit + } + + @After + fun tearDown() { + Dispatchers.resetMain() + } + + @Test + fun `getCourseData success`() = runTest { + coEvery { interactor.getCourseStructureFlow(courseId, false) } returns flow { + emit( + courseStructure + ) + } + coEvery { interactor.getCourseStatusFlow(courseId) } returns flow { + emit( + courseComponentStatus + ) + } + coEvery { interactor.getCourseDatesFlow(courseId) } returns flow { emit(courseDatesResult) } + coEvery { + interactor.getCourseProgress( + courseId, + false, + true + ) + } returns flow { emit(courseProgress) } + coEvery { interactor.getVideoProgress("video1") } returns videoProgress + + val viewModel = CourseHomeViewModel( + courseId = courseId, + courseTitle = courseTitle, + context = context, + config = config, + interactor = interactor, + resourceManager = resourceManager, + courseNotifier = courseNotifier, + networkConnection = networkConnection, + preferencesManager = preferencesManager, + analytics = analytics, + downloadDialogManager = downloadDialogManager, + fileUtil = fileUtil, + courseRouter = courseRouter, + coreAnalytics = coreAnalytics, + downloadDao = downloadDao, + workerController = workerController, + downloadHelper = downloadHelper + ) + + advanceUntilIdle() + + coVerify { interactor.getCourseStructureFlow(courseId, false) } + coVerify { interactor.getCourseStatusFlow(courseId) } + coVerify { interactor.getCourseDatesFlow(courseId) } + coVerify { interactor.getCourseProgress(courseId, false, true) } + + assertTrue(viewModel.uiState.value is CourseHomeUIState.CourseData) + val courseData = viewModel.uiState.value as CourseHomeUIState.CourseData + assertEquals(courseId, courseData.courseStructure.id) + assertEquals(courseTitle, courseData.courseStructure.name) + assertEquals(courseProgress, courseData.courseProgress) + } + + @Test + fun `getCourseData no internet connection error`() = runTest { + coEvery { + interactor.getCourseStructureFlow( + courseId, + false + ) + } returns flow { throw UnknownHostException() } + coEvery { interactor.getCourseStatusFlow(courseId) } returns flow { + emit( + courseComponentStatus + ) + } + coEvery { interactor.getCourseDatesFlow(courseId) } returns flow { emit(courseDatesResult) } + coEvery { + interactor.getCourseProgress( + courseId, + false, + true + ) + } returns flow { emit(courseProgress) } + + val viewModel = CourseHomeViewModel( + courseId = courseId, + courseTitle = courseTitle, + context = context, + config = config, + interactor = interactor, + resourceManager = resourceManager, + courseNotifier = courseNotifier, + networkConnection = networkConnection, + preferencesManager = preferencesManager, + analytics = analytics, + downloadDialogManager = downloadDialogManager, + fileUtil = fileUtil, + courseRouter = courseRouter, + coreAnalytics = coreAnalytics, + downloadDao = downloadDao, + workerController = workerController, + downloadHelper = downloadHelper + ) + + advanceUntilIdle() + + assertTrue(viewModel.uiState.value !is CourseHomeUIState.CourseData) + } + + @Suppress("TooGenericExceptionThrown") + @Test + fun `getCourseData unknown error`() = runTest { + coEvery { + interactor.getCourseStructureFlow( + courseId, + false + ) + } returns flow { throw Exception() } + coEvery { interactor.getCourseStatusFlow(courseId) } returns flow { + emit( + courseComponentStatus + ) + } + coEvery { interactor.getCourseDatesFlow(courseId) } returns flow { emit(courseDatesResult) } + coEvery { + interactor.getCourseProgress( + courseId, + false, + true + ) + } returns flow { emit(courseProgress) } + + val viewModel = CourseHomeViewModel( + courseId = courseId, + courseTitle = courseTitle, + context = context, + config = config, + interactor = interactor, + resourceManager = resourceManager, + courseNotifier = courseNotifier, + networkConnection = networkConnection, + preferencesManager = preferencesManager, + analytics = analytics, + downloadDialogManager = downloadDialogManager, + fileUtil = fileUtil, + courseRouter = courseRouter, + coreAnalytics = coreAnalytics, + downloadDao = downloadDao, + workerController = workerController, + downloadHelper = downloadHelper + ) + + advanceUntilIdle() + + assertTrue(viewModel.uiState.value !is CourseHomeUIState.CourseData) + } + + @Test + fun `saveDownloadModels with wifi only enabled but no wifi connection`() = runTest { + every { preferencesManager.videoSettings.wifiDownloadOnly } returns true + every { networkConnection.isWifiConnected() } returns false + + coEvery { interactor.getCourseStructureFlow(courseId, false) } returns flow { + emit( + courseStructure + ) + } + coEvery { interactor.getCourseStatusFlow(courseId) } returns flow { + emit( + courseComponentStatus + ) + } + coEvery { interactor.getCourseDatesFlow(courseId) } returns flow { emit(courseDatesResult) } + coEvery { + interactor.getCourseProgress( + courseId, + false, + true + ) + } returns flow { emit(courseProgress) } + + val viewModel = CourseHomeViewModel( + courseId = courseId, + courseTitle = courseTitle, + context = context, + config = config, + interactor = interactor, + resourceManager = resourceManager, + courseNotifier = courseNotifier, + networkConnection = networkConnection, + preferencesManager = preferencesManager, + analytics = analytics, + downloadDialogManager = downloadDialogManager, + fileUtil = fileUtil, + courseRouter = courseRouter, + coreAnalytics = coreAnalytics, + downloadDao = downloadDao, + workerController = workerController, + downloadHelper = downloadHelper + ) + + advanceUntilIdle() + + viewModel.saveDownloadModels("/test/path", courseId, "test-block-id") + + coVerify(exactly = 0) { workerController.saveModels(any()) } + } + + @Test + fun `resetCourseDatesBanner success`() = runTest { + coEvery { interactor.resetCourseDates(courseId) } returns resetCourseDates + coEvery { interactor.getCourseStructureFlow(courseId, false) } returns flow { + emit( + courseStructure + ) + } + coEvery { interactor.getCourseStatusFlow(courseId) } returns flow { + emit( + courseComponentStatus + ) + } + coEvery { interactor.getCourseDatesFlow(courseId) } returns flow { emit(courseDatesResult) } + coEvery { + interactor.getCourseProgress( + courseId, + false, + true + ) + } returns flow { emit(courseProgress) } + + val viewModel = CourseHomeViewModel( + courseId = courseId, + courseTitle = courseTitle, + context = context, + config = config, + interactor = interactor, + resourceManager = resourceManager, + courseNotifier = courseNotifier, + networkConnection = networkConnection, + preferencesManager = preferencesManager, + analytics = analytics, + downloadDialogManager = downloadDialogManager, + fileUtil = fileUtil, + courseRouter = courseRouter, + coreAnalytics = coreAnalytics, + downloadDao = downloadDao, + workerController = workerController, + downloadHelper = downloadHelper + ) + + advanceUntilIdle() + + var resetResult: Boolean? = null + + viewModel.resetCourseDatesBanner { success -> + resetResult = success + } + + advanceUntilIdle() + + coVerify { interactor.resetCourseDates(courseId) } + coVerify { courseNotifier.send(CourseDatesShifted) } + assertEquals(true, resetResult) + } + + @Test + fun `resetCourseDatesBanner with internet error`() = runTest { + coEvery { interactor.resetCourseDates(courseId) } throws UnknownHostException() + coEvery { interactor.getCourseStructureFlow(courseId, false) } returns flow { + emit( + courseStructure + ) + } + coEvery { interactor.getCourseStatusFlow(courseId) } returns flow { + emit( + courseComponentStatus + ) + } + coEvery { interactor.getCourseDatesFlow(courseId) } returns flow { emit(courseDatesResult) } + coEvery { + interactor.getCourseProgress( + courseId, + false, + true + ) + } returns flow { emit(courseProgress) } + + val viewModel = CourseHomeViewModel( + courseId = courseId, + courseTitle = courseTitle, + context = context, + config = config, + interactor = interactor, + resourceManager = resourceManager, + courseNotifier = courseNotifier, + networkConnection = networkConnection, + preferencesManager = preferencesManager, + analytics = analytics, + downloadDialogManager = downloadDialogManager, + fileUtil = fileUtil, + courseRouter = courseRouter, + coreAnalytics = coreAnalytics, + downloadDao = downloadDao, + workerController = workerController, + downloadHelper = downloadHelper + ) + + advanceUntilIdle() + + var resetResult: Boolean? = null + + viewModel.resetCourseDatesBanner { success -> + resetResult = success + } + + advanceUntilIdle() + + coVerify { interactor.resetCourseDates(courseId) } + coVerify(exactly = 0) { courseNotifier.send(CourseDatesShifted) } + assertEquals(false, resetResult) + } + + @Test + fun `logVideoClick analytics event`() = runTest { + coEvery { interactor.getCourseStructureFlow(courseId, false) } returns flow { + emit( + courseStructure + ) + } + coEvery { interactor.getCourseStatusFlow(courseId) } returns flow { + emit( + courseComponentStatus + ) + } + coEvery { interactor.getCourseDatesFlow(courseId) } returns flow { emit(courseDatesResult) } + coEvery { + interactor.getCourseProgress( + courseId, + false, + true + ) + } returns flow { emit(courseProgress) } + + val viewModel = CourseHomeViewModel( + courseId = courseId, + courseTitle = courseTitle, + context = context, + config = config, + interactor = interactor, + resourceManager = resourceManager, + courseNotifier = courseNotifier, + networkConnection = networkConnection, + preferencesManager = preferencesManager, + analytics = analytics, + downloadDialogManager = downloadDialogManager, + fileUtil = fileUtil, + courseRouter = courseRouter, + coreAnalytics = coreAnalytics, + downloadDao = downloadDao, + workerController = workerController, + downloadHelper = downloadHelper + ) + + advanceUntilIdle() + + viewModel.logVideoClick("video1") + + verify { + analytics.logEvent( + CourseAnalyticsEvent.COURSE_HOME_VIDEO_CLICK.eventName, + match { + it[CourseAnalyticsKey.NAME.key] == CourseAnalyticsEvent.COURSE_HOME_VIDEO_CLICK.biValue && + it[CourseAnalyticsKey.COURSE_ID.key] == courseId && + it[CourseAnalyticsKey.COURSE_NAME.key] == courseTitle && + it[CourseAnalyticsKey.BLOCK_ID.key] == "video1" + } + ) + } + } + + @Test + fun `logAssignmentClick analytics event`() = runTest { + coEvery { interactor.getCourseStructureFlow(courseId, false) } returns flow { + emit( + courseStructure + ) + } + coEvery { interactor.getCourseStatusFlow(courseId) } returns flow { + emit( + courseComponentStatus + ) + } + coEvery { interactor.getCourseDatesFlow(courseId) } returns flow { emit(courseDatesResult) } + coEvery { + interactor.getCourseProgress( + courseId, + false, + true + ) + } returns flow { emit(courseProgress) } + + val viewModel = CourseHomeViewModel( + courseId = courseId, + courseTitle = courseTitle, + context = context, + config = config, + interactor = interactor, + resourceManager = resourceManager, + courseNotifier = courseNotifier, + networkConnection = networkConnection, + preferencesManager = preferencesManager, + analytics = analytics, + downloadDialogManager = downloadDialogManager, + fileUtil = fileUtil, + courseRouter = courseRouter, + coreAnalytics = coreAnalytics, + downloadDao = downloadDao, + workerController = workerController, + downloadHelper = downloadHelper + ) + + advanceUntilIdle() + + viewModel.logAssignmentClick("assignment1") + + verify { + analytics.logEvent( + CourseAnalyticsEvent.COURSE_HOME_ASSIGNMENT_CLICK.eventName, + match { + it[CourseAnalyticsKey.NAME.key] == CourseAnalyticsEvent.COURSE_HOME_ASSIGNMENT_CLICK.biValue && + it[CourseAnalyticsKey.COURSE_ID.key] == courseId && + it[CourseAnalyticsKey.COURSE_NAME.key] == courseTitle && + it[CourseAnalyticsKey.BLOCK_ID.key] == "assignment1" + } + ) + } + } + + @Test + fun `viewCertificateTappedEvent analytics event`() = runTest { + coEvery { interactor.getCourseStructureFlow(courseId, false) } returns flow { + emit( + courseStructure + ) + } + coEvery { interactor.getCourseStatusFlow(courseId) } returns flow { + emit( + courseComponentStatus + ) + } + coEvery { interactor.getCourseDatesFlow(courseId) } returns flow { emit(courseDatesResult) } + coEvery { + interactor.getCourseProgress( + courseId, + false, + true + ) + } returns flow { emit(courseProgress) } + + val viewModel = CourseHomeViewModel( + courseId = courseId, + courseTitle = courseTitle, + context = context, + config = config, + interactor = interactor, + resourceManager = resourceManager, + courseNotifier = courseNotifier, + networkConnection = networkConnection, + preferencesManager = preferencesManager, + analytics = analytics, + downloadDialogManager = downloadDialogManager, + fileUtil = fileUtil, + courseRouter = courseRouter, + coreAnalytics = coreAnalytics, + downloadDao = downloadDao, + workerController = workerController, + downloadHelper = downloadHelper + ) + + advanceUntilIdle() + + viewModel.viewCertificateTappedEvent() + + verify { + analytics.logEvent( + CourseAnalyticsEvent.VIEW_CERTIFICATE.eventName, + match { + it[CourseAnalyticsKey.NAME.key] == CourseAnalyticsEvent.VIEW_CERTIFICATE.biValue && + it[CourseAnalyticsKey.COURSE_ID.key] == courseId + } + ) + } + } + + @Test + fun `getCourseProgress success`() = runTest { + coEvery { interactor.getCourseStructureFlow(courseId, false) } returns flow { + emit( + courseStructure + ) + } + coEvery { interactor.getCourseStatusFlow(courseId) } returns flow { + emit( + courseComponentStatus + ) + } + coEvery { interactor.getCourseDatesFlow(courseId) } returns flow { emit(courseDatesResult) } + coEvery { + interactor.getCourseProgress( + courseId, + false, + true + ) + } returns flow { emit(courseProgress) } + + val viewModel = CourseHomeViewModel( + courseId = courseId, + courseTitle = courseTitle, + context = context, + config = config, + interactor = interactor, + resourceManager = resourceManager, + courseNotifier = courseNotifier, + networkConnection = networkConnection, + preferencesManager = preferencesManager, + analytics = analytics, + downloadDialogManager = downloadDialogManager, + fileUtil = fileUtil, + courseRouter = courseRouter, + coreAnalytics = coreAnalytics, + downloadDao = downloadDao, + workerController = workerController, + downloadHelper = downloadHelper + ) + + advanceUntilIdle() + + viewModel.getCourseProgress() + + coVerify { interactor.getCourseProgress(courseId, false, true) } + } + + @Test + fun `CourseStructureUpdated notifier event`() = runTest { + coEvery { interactor.getCourseStructureFlow(courseId, false) } returns flow { + emit( + courseStructure + ) + } + coEvery { interactor.getCourseStatusFlow(courseId) } returns flow { + emit( + courseComponentStatus + ) + } + coEvery { interactor.getCourseDatesFlow(courseId) } returns flow { emit(courseDatesResult) } + coEvery { + interactor.getCourseProgress( + courseId, + false, + true + ) + } returns flow { emit(courseProgress) } + + every { courseNotifier.notifier } returns flow { emit(CourseStructureUpdated(courseId)) } + + val viewModel = CourseHomeViewModel( + courseId = courseId, + courseTitle = courseTitle, + context = context, + config = config, + interactor = interactor, + resourceManager = resourceManager, + courseNotifier = courseNotifier, + networkConnection = networkConnection, + preferencesManager = preferencesManager, + analytics = analytics, + downloadDialogManager = downloadDialogManager, + fileUtil = fileUtil, + courseRouter = courseRouter, + coreAnalytics = coreAnalytics, + downloadDao = downloadDao, + workerController = workerController, + downloadHelper = downloadHelper + ) + + advanceUntilIdle() + + coVerify(atLeast = 2) { interactor.getCourseStructureFlow(courseId, false) } + } + + @Test + fun `CourseOpenBlock notifier event`() = runTest { + coEvery { interactor.getCourseStructureFlow(courseId, false) } returns flow { + emit( + courseStructure + ) + } + coEvery { interactor.getCourseStatusFlow(courseId) } returns flow { + emit( + courseComponentStatus + ) + } + coEvery { interactor.getCourseDatesFlow(courseId) } returns flow { emit(courseDatesResult) } + coEvery { + interactor.getCourseProgress( + courseId, + false, + true + ) + } returns flow { emit(courseProgress) } + + every { courseNotifier.notifier } returns flow { emit(CourseOpenBlock("test-block-id")) } + + val viewModel = CourseHomeViewModel( + courseId = courseId, + courseTitle = courseTitle, + context = context, + config = config, + interactor = interactor, + resourceManager = resourceManager, + courseNotifier = courseNotifier, + networkConnection = networkConnection, + preferencesManager = preferencesManager, + analytics = analytics, + downloadDialogManager = downloadDialogManager, + fileUtil = fileUtil, + courseRouter = courseRouter, + coreAnalytics = coreAnalytics, + downloadDao = downloadDao, + workerController = workerController, + downloadHelper = downloadHelper + ) + + advanceUntilIdle() + } + + @Test + fun `CourseProgressLoaded notifier event`() = runTest { + coEvery { interactor.getCourseStructureFlow(courseId, false) } returns flow { + emit( + courseStructure + ) + } + coEvery { interactor.getCourseStatusFlow(courseId) } returns flow { + emit( + courseComponentStatus + ) + } + coEvery { interactor.getCourseDatesFlow(courseId) } returns flow { emit(courseDatesResult) } + coEvery { + interactor.getCourseProgress( + courseId, + false, + true + ) + } returns flow { emit(courseProgress) } + + every { courseNotifier.notifier } returns flow { emit(CourseProgressLoaded) } + + val viewModel = CourseHomeViewModel( + courseId = courseId, + courseTitle = courseTitle, + context = context, + config = config, + interactor = interactor, + resourceManager = resourceManager, + courseNotifier = courseNotifier, + networkConnection = networkConnection, + preferencesManager = preferencesManager, + analytics = analytics, + downloadDialogManager = downloadDialogManager, + fileUtil = fileUtil, + courseRouter = courseRouter, + coreAnalytics = coreAnalytics, + downloadDao = downloadDao, + workerController = workerController, + downloadHelper = downloadHelper + ) + + advanceUntilIdle() + + coVerify(atLeast = 2) { interactor.getCourseProgress(courseId, false, true) } + } + + @Test + fun `isCourseDropdownNavigationEnabled property`() = runTest { + every { config.getCourseUIConfig().isCourseDropdownNavigationEnabled } returns true + + coEvery { interactor.getCourseStructureFlow(courseId, false) } returns flow { + emit( + courseStructure + ) + } + coEvery { interactor.getCourseStatusFlow(courseId) } returns flow { + emit( + courseComponentStatus + ) + } + coEvery { interactor.getCourseDatesFlow(courseId) } returns flow { emit(courseDatesResult) } + coEvery { + interactor.getCourseProgress( + courseId, + false, + true + ) + } returns flow { emit(courseProgress) } + + val viewModel = CourseHomeViewModel( + courseId = courseId, + courseTitle = courseTitle, + context = context, + config = config, + interactor = interactor, + resourceManager = resourceManager, + courseNotifier = courseNotifier, + networkConnection = networkConnection, + preferencesManager = preferencesManager, + analytics = analytics, + downloadDialogManager = downloadDialogManager, + fileUtil = fileUtil, + courseRouter = courseRouter, + coreAnalytics = coreAnalytics, + downloadDao = downloadDao, + workerController = workerController, + downloadHelper = downloadHelper + ) + + assertTrue(viewModel.isCourseDropdownNavigationEnabled) + } +} diff --git a/course/src/test/java/org/openedx/course/presentation/section/CourseSectionViewModelTest.kt b/course/src/test/java/org/openedx/course/presentation/section/CourseSectionViewModelTest.kt index 7336e9307..685311e9e 100644 --- a/course/src/test/java/org/openedx/course/presentation/section/CourseSectionViewModelTest.kt +++ b/course/src/test/java/org/openedx/course/presentation/section/CourseSectionViewModelTest.kt @@ -38,11 +38,11 @@ import org.openedx.core.module.db.DownloadModelEntity import org.openedx.core.module.db.DownloadedState import org.openedx.core.module.db.FileType import org.openedx.core.presentation.CoreAnalytics -import org.openedx.core.presentation.course.CourseViewMode import org.openedx.core.system.connection.NetworkConnection import org.openedx.core.system.notifier.CourseNotifier import org.openedx.course.domain.interactor.CourseInteractor import org.openedx.course.presentation.CourseAnalytics +import org.openedx.course.presentation.unit.container.CourseViewMode import org.openedx.foundation.presentation.UIMessage import org.openedx.foundation.system.ResourceManager import java.net.UnknownHostException diff --git a/course/src/test/java/org/openedx/course/presentation/unit/container/CourseUnitContainerViewModelTest.kt b/course/src/test/java/org/openedx/course/presentation/unit/container/CourseUnitContainerViewModelTest.kt index becf35187..909fe0e8f 100644 --- a/course/src/test/java/org/openedx/course/presentation/unit/container/CourseUnitContainerViewModelTest.kt +++ b/course/src/test/java/org/openedx/course/presentation/unit/container/CourseUnitContainerViewModelTest.kt @@ -25,7 +25,6 @@ import org.openedx.core.domain.model.Block import org.openedx.core.domain.model.BlockCounts import org.openedx.core.domain.model.CourseStructure import org.openedx.core.domain.model.CoursewareAccess -import org.openedx.core.presentation.course.CourseViewMode import org.openedx.core.system.connection.NetworkConnection import org.openedx.core.system.notifier.CourseNotifier import org.openedx.course.domain.interactor.CourseInteractor diff --git a/whatsnew/src/main/java/org/openedx/whatsnew/presentation/ui/WhatsNewUI.kt b/whatsnew/src/main/java/org/openedx/whatsnew/presentation/ui/WhatsNewUI.kt index d6d7c619d..2c2765619 100644 --- a/whatsnew/src/main/java/org/openedx/whatsnew/presentation/ui/WhatsNewUI.kt +++ b/whatsnew/src/main/java/org/openedx/whatsnew/presentation/ui/WhatsNewUI.kt @@ -1,21 +1,17 @@ package org.openedx.whatsnew.presentation.ui import androidx.compose.animation.AnimatedContent -import androidx.compose.animation.animateColorAsState -import androidx.compose.animation.core.animateDpAsState import androidx.compose.animation.core.animateFloatAsState import androidx.compose.animation.core.tween import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut import androidx.compose.animation.togetherWith import androidx.compose.foundation.BorderStroke -import androidx.compose.foundation.Canvas import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.material.Button import androidx.compose.material.ButtonDefaults @@ -31,15 +27,10 @@ import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.alpha -import androidx.compose.ui.geometry.CornerRadius -import androidx.compose.ui.geometry.Offset -import androidx.compose.ui.geometry.Size -import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview -import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import org.openedx.core.ui.theme.OpenEdXTheme import org.openedx.core.ui.theme.appColors @@ -47,95 +38,6 @@ import org.openedx.core.ui.theme.appShapes import org.openedx.core.ui.theme.appTypography import org.openedx.whatsnew.R -@Composable -fun PageIndicator( - numberOfPages: Int, - modifier: Modifier = Modifier, - selectedPage: Int = 0, - selectedColor: Color = MaterialTheme.appColors.info, - previousUnselectedColor: Color = MaterialTheme.appColors.cardViewBorder, - nextUnselectedColor: Color = MaterialTheme.appColors.textFieldBorder, - defaultRadius: Dp = 20.dp, - selectedLength: Dp = 60.dp, - space: Dp = 30.dp, - animationDurationInMillis: Int = 300, -) { - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(space), - modifier = modifier, - ) { - for (i in 0 until numberOfPages) { - val isSelected = i == selectedPage - val unselectedColor = - if (i < selectedPage) previousUnselectedColor else nextUnselectedColor - PageIndicatorView( - isSelected = isSelected, - selectedColor = selectedColor, - defaultColor = unselectedColor, - defaultRadius = defaultRadius, - selectedLength = selectedLength, - animationDurationInMillis = animationDurationInMillis, - ) - } - } -} - -@Composable -fun PageIndicatorView( - isSelected: Boolean, - selectedColor: Color, - defaultColor: Color, - defaultRadius: Dp, - selectedLength: Dp, - animationDurationInMillis: Int, - modifier: Modifier = Modifier, -) { - val color: Color by animateColorAsState( - targetValue = if (isSelected) { - selectedColor - } else { - defaultColor - }, - animationSpec = tween( - durationMillis = animationDurationInMillis, - ), - label = "" - ) - val width: Dp by animateDpAsState( - targetValue = if (isSelected) { - selectedLength - } else { - defaultRadius - }, - animationSpec = tween( - durationMillis = animationDurationInMillis, - ), - label = "" - ) - - Canvas( - modifier = modifier - .size( - width = width, - height = defaultRadius, - ), - ) { - drawRoundRect( - color = color, - topLeft = Offset.Zero, - size = Size( - width = width.toPx(), - height = defaultRadius.toPx(), - ), - cornerRadius = CornerRadius( - x = defaultRadius.toPx(), - y = defaultRadius.toPx(), - ), - ) - } -} - @Composable fun NavigationUnitsButtons( hasPrevPage: Boolean, @@ -304,14 +206,3 @@ private fun NavigationUnitsButtonsPrevInTheEnd() { ) } } - -@Preview -@Composable -private fun PageIndicatorViewPreview() { - OpenEdXTheme { - PageIndicator( - numberOfPages = 4, - selectedPage = 2 - ) - } -} diff --git a/whatsnew/src/main/java/org/openedx/whatsnew/presentation/whatsnew/WhatsNewFragment.kt b/whatsnew/src/main/java/org/openedx/whatsnew/presentation/whatsnew/WhatsNewFragment.kt index 0cab35466..22dc96737 100644 --- a/whatsnew/src/main/java/org/openedx/whatsnew/presentation/whatsnew/WhatsNewFragment.kt +++ b/whatsnew/src/main/java/org/openedx/whatsnew/presentation/whatsnew/WhatsNewFragment.kt @@ -56,6 +56,7 @@ import androidx.fragment.app.Fragment import kotlinx.coroutines.launch import org.koin.androidx.viewmodel.ext.android.viewModel import org.koin.core.parameter.parametersOf +import org.openedx.core.ui.PageIndicator import org.openedx.core.ui.calculateCurrentOffsetForPage import org.openedx.core.ui.statusBarsInset import org.openedx.core.ui.theme.OpenEdXTheme @@ -67,7 +68,6 @@ import org.openedx.foundation.presentation.windowSizeValue import org.openedx.whatsnew.domain.model.WhatsNewItem import org.openedx.whatsnew.domain.model.WhatsNewMessage import org.openedx.whatsnew.presentation.ui.NavigationUnitsButtons -import org.openedx.whatsnew.presentation.ui.PageIndicator import org.openedx.whatsnew.presentation.whatsnew.WhatsNewFragment.Companion.BASE_ALPHA_VALUE class WhatsNewFragment : Fragment() { From a3b5977e3faea37aad564d5efb08b721fc8bec6c Mon Sep 17 00:00:00 2001 From: PavloNetrebchuk <141041606+PavloNetrebchuk@users.noreply.github.com> Date: Thu, 23 Oct 2025 13:40:51 +0300 Subject: [PATCH 24/24] feat: [FC-0092]: Video navigation (#464) * feat: course home pager and pager indicator with navigation * feat: course completion pager tab * feat: move HomeNavigationRow to bottom bar * feat: course home pages videos card * feat: course home pages assignment card * feat: course home pages grades card * feat: added empty states * feat: CaughtUpMessage * feat: A11y * feat: detekt fixes * feat: changes according demo feedback * feat: course home analytic * feat: CourseHomeViewModelTest * fix: detekt fix and changes according PR review * feat: performance improvements * feat: changes according PR feedback * feat: changes according PR feedback * feat: video navigation * feat: minor ui fixes * feat: next prev buttons logic update for video view mode * fix: db error fix * fix: button arrow fix * fix: changes according designer review * fix: obfuscation fix * fix: changes according code review feedback * fix: update androidyoutubeplayer to fix error 15 --------- Co-authored-by: IvanStepanok --- app/proguard-rules.pro | 113 +- .../org.openedx.app.room.AppDatabase/5.json | 1152 +++++++++++++++++ .../main/java/org/openedx/app/di/AppModule.kt | 2 + .../java/org/openedx/app/di/ScreenModule.kt | 5 +- .../java/org/openedx/app/room/AppDatabase.kt | 5 +- build.gradle | 2 +- .../room/CourseEnrollmentDetailsEntity.kt | 4 +- .../core/domain/helper/VideoPreviewHelper.kt | 62 + .../org/openedx/core/ui/theme/Colors.kt | 16 +- course/build.gradle | 3 +- .../course/data/storage/CourseConverter.kt | 18 +- .../presentation/home/CourseHomeViewModel.kt | 12 +- .../home/VideosHomePagerCardContent.kt | 53 +- .../course/presentation/ui/CourseUI.kt | 117 +- .../container/CourseUnitContainerFragment.kt | 253 +++- .../container/CourseUnitContainerViewModel.kt | 157 ++- .../video/YoutubeVideoFullScreenFragment.kt | 6 +- .../unit/video/YoutubeVideoUnitFragment.kt | 6 +- .../videos/CourseVideoViewModel.kt | 15 +- .../fragment_course_unit_container.xml | 10 +- .../layout/fragment_course_unit_container.xml | 10 +- .../home/CourseHomeViewModelTest.kt | 34 +- .../CourseUnitContainerViewModelTest.kt | 153 ++- .../videos/CourseVideoViewModelTest.kt | 21 +- default_config/dev/config.yaml | 2 + 25 files changed, 2003 insertions(+), 228 deletions(-) create mode 100644 app/schemas/org.openedx.app.room.AppDatabase/5.json create mode 100644 core/src/main/java/org/openedx/core/domain/helper/VideoPreviewHelper.kt diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro index 0c1f551c0..9e4670b9e 100644 --- a/app/proguard-rules.pro +++ b/app/proguard-rules.pro @@ -3,6 +3,10 @@ # removes such information by default, so configure it to keep all of it. -keepattributes Signature +# CRITICAL: Keep generic type information for TypeToken to work properly +-keepattributes RuntimeVisibleAnnotations, RuntimeVisibleParameterAnnotations +-keepattributes *Annotation* + # For using GSON @Expose annotation -keepattributes *Annotation* @@ -23,8 +27,74 @@ } # Retain generic signatures of TypeToken and its subclasses with R8 version 3.0 and higher. --keep,allowobfuscation,allowshrinking class com.google.gson.reflect.TypeToken --keep,allowobfuscation,allowshrinking class * extends com.google.gson.reflect.TypeToken +# CRITICAL: Do NOT allow obfuscation or shrinking of TypeToken - it needs to preserve generic type information +-keep class com.google.gson.reflect.TypeToken +-keep class * extends com.google.gson.reflect.TypeToken + +# Keep TypeToken constructors and methods to preserve generic type information +-keepclassmembers class com.google.gson.reflect.TypeToken { + (...); + ; +} + +# Keep all Gson reflection classes that handle generic types +-keep class com.google.gson.reflect.** { *; } + +# CRITICAL: Keep Google Guava TypeToken and TypeCapture classes (used by Gson) +-keep class com.google.common.reflect.TypeToken { *; } +-keep class com.google.common.reflect.TypeCapture { *; } +-keep class com.google.common.reflect.TypeToken$* { *; } +-keep class com.google.common.reflect.TypeCapture$* { *; } + +# Keep all anonymous subclasses of TypeToken (created by object : TypeToken() {}) +-keep class * extends com.google.common.reflect.TypeToken { *; } +-keep class * extends com.google.gson.reflect.TypeToken { *; } + +# Keep Gson TypeAdapter classes used by Room TypeConverters +-keep class * extends com.google.gson.TypeAdapter +-keep class * implements com.google.gson.TypeAdapterFactory + +# Keep Room TypeConverters that use Gson (important for complex types like List) +-keep @androidx.room.TypeConverter class * { *; } +-keepclassmembers class * { + @androidx.room.TypeConverter ; +} + +# Keep generic type information for Room entities with complex types +-keepclassmembers class org.openedx.**.data.model.room.** { + ; + (...); + * mapToDomain(); + * mapToRoomEntity(); + * mapToEntity(); +} + +# CRITICAL: Keep the CourseConverter and all its TypeToken usage +-keep class org.openedx.course.data.storage.CourseConverter { *; } +-keepclassmembers class org.openedx.course.data.storage.CourseConverter { + (...); + ; +} + +# Keep anonymous TypeToken subclasses created in CourseConverter +-keep class org.openedx.course.data.storage.CourseConverter$* { *; } + +# CRITICAL: Prevent obfuscation of CourseConverter methods that use TypeToken +-keepclassmembers,allowobfuscation class org.openedx.course.data.storage.CourseConverter { + @androidx.room.TypeConverter ; +} + +# Keep all TypeConverter classes that use Gson +-keep class org.openedx.discovery.data.converter.DiscoveryConverter { *; } + +# Keep the specific TypeToken usage patterns in TypeConverters +-keepclassmembers class org.openedx.**.data.storage.** { + @androidx.room.TypeConverter ; +} + +-keepclassmembers class org.openedx.**.data.converter.** { + @androidx.room.TypeConverter ; +} ##---------------End: proguard configuration for Gson ---------- -keepclassmembers class * extends java.lang.Enum { @@ -33,6 +103,45 @@ public static ** valueOf(java.lang.String); } +##---------------Begin: proguard configuration for Kotlin Coroutines ---------- +# Keep all coroutine-related classes and methods +-keep class kotlinx.coroutines.** { *; } +-keep class kotlin.coroutines.** { *; } +-keep class kotlin.coroutines.intrinsics.** { *; } + +# Keep suspend functions and coroutine builders +-keepclassmembers class * { + kotlin.coroutines.Continuation *(...); +} + +# Keep coroutine context and related classes +-keep class kotlinx.coroutines.CoroutineContext$* { *; } + +# Keep Flow and StateFlow classes +-keep class kotlinx.coroutines.flow.** { *; } + +# Keep coroutine dispatchers +-keep class kotlinx.coroutines.Dispatchers { *; } +-keep class kotlinx.coroutines.Dispatchers$* { *; } + +# Keep coroutine scope and job classes +-keep class kotlinx.coroutines.CoroutineScope { *; } +-keep class kotlinx.coroutines.Job { *; } +-keep class kotlinx.coroutines.Job$* { *; } + +# Keep coroutine intrinsics that are causing the error +-keep class kotlin.coroutines.intrinsics.IntrinsicsKt { *; } +-keep class kotlin.coroutines.intrinsics.IntrinsicsKt$* { *; } + +# Keep suspend function markers +-keepclassmembers class * { + @kotlin.coroutines.RestrictsSuspension ; +} + +# Keep coroutine-related annotations +-keep @kotlin.coroutines.RestrictsSuspension class * { *; } +##---------------End: proguard configuration for Kotlin Coroutines ---------- + -dontwarn org.bouncycastle.jsse.BCSSLParameters -dontwarn org.bouncycastle.jsse.BCSSLSocket -dontwarn org.bouncycastle.jsse.provider.BouncyCastleJsseProvider diff --git a/app/schemas/org.openedx.app.room.AppDatabase/5.json b/app/schemas/org.openedx.app.room.AppDatabase/5.json new file mode 100644 index 000000000..3b42cabf3 --- /dev/null +++ b/app/schemas/org.openedx.app.room.AppDatabase/5.json @@ -0,0 +1,1152 @@ +{ + "formatVersion": 1, + "database": { + "version": 5, + "identityHash": "09f6fc49a2f7a494d27f3290d7bae350", + "entities": [ + { + "tableName": "course_discovery_table", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `blocksUrl` TEXT NOT NULL, `courseId` TEXT NOT NULL, `effort` TEXT NOT NULL, `enrollmentStart` TEXT NOT NULL, `enrollmentEnd` TEXT NOT NULL, `hidden` INTEGER NOT NULL, `invitationOnly` INTEGER NOT NULL, `mobileAvailable` INTEGER NOT NULL, `name` TEXT NOT NULL, `number` TEXT NOT NULL, `org` TEXT NOT NULL, `pacing` TEXT NOT NULL, `shortDescription` TEXT NOT NULL, `start` TEXT NOT NULL, `end` TEXT NOT NULL, `startDisplay` TEXT NOT NULL, `startType` TEXT NOT NULL, `overview` TEXT NOT NULL, `isEnrolled` INTEGER NOT NULL, `bannerImage` TEXT, `courseImage` TEXT, `courseVideo` TEXT, `image` TEXT, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "blocksUrl", + "columnName": "blocksUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "courseId", + "columnName": "courseId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "effort", + "columnName": "effort", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "enrollmentStart", + "columnName": "enrollmentStart", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "enrollmentEnd", + "columnName": "enrollmentEnd", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "hidden", + "columnName": "hidden", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "invitationOnly", + "columnName": "invitationOnly", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "mobileAvailable", + "columnName": "mobileAvailable", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "number", + "columnName": "number", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "org", + "columnName": "org", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "pacing", + "columnName": "pacing", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "shortDescription", + "columnName": "shortDescription", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "start", + "columnName": "start", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "end", + "columnName": "end", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "startDisplay", + "columnName": "startDisplay", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "startType", + "columnName": "startType", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "overview", + "columnName": "overview", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "isEnrolled", + "columnName": "isEnrolled", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "media.bannerImage", + "columnName": "bannerImage", + "affinity": "TEXT" + }, + { + "fieldPath": "media.courseImage", + "columnName": "courseImage", + "affinity": "TEXT" + }, + { + "fieldPath": "media.courseVideo", + "columnName": "courseVideo", + "affinity": "TEXT" + }, + { + "fieldPath": "media.image", + "columnName": "image", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + } + }, + { + "tableName": "course_enrolled_table", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`courseId` TEXT NOT NULL, `auditAccessExpires` TEXT NOT NULL, `created` TEXT NOT NULL, `mode` TEXT NOT NULL, `isActive` INTEGER NOT NULL, `id` TEXT NOT NULL, `name` TEXT NOT NULL, `number` TEXT NOT NULL, `org` TEXT NOT NULL, `start` TEXT NOT NULL, `startDisplay` TEXT NOT NULL, `startType` TEXT NOT NULL, `end` TEXT NOT NULL, `dynamicUpgradeDeadline` TEXT NOT NULL, `subscriptionId` TEXT NOT NULL, `course_image_link` TEXT NOT NULL, `courseAbout` TEXT NOT NULL, `courseUpdates` TEXT NOT NULL, `courseHandouts` TEXT NOT NULL, `discussionUrl` TEXT NOT NULL, `videoOutline` TEXT NOT NULL, `isSelfPaced` INTEGER NOT NULL, `hasAccess` INTEGER, `errorCode` TEXT, `developerMessage` TEXT, `userMessage` TEXT, `additionalContextUserMessage` TEXT, `userFragment` TEXT, `bannerImage` TEXT, `courseImage` TEXT, `courseVideo` TEXT, `image` TEXT, `facebook` TEXT NOT NULL, `twitter` TEXT NOT NULL, `certificateURL` TEXT, `assignments_completed` INTEGER NOT NULL, `total_assignments_count` INTEGER NOT NULL, `lastVisitedModuleId` TEXT, `lastVisitedModulePath` TEXT, `lastVisitedBlockId` TEXT, `lastVisitedUnitDisplayName` TEXT, `futureAssignments` TEXT, `pastAssignments` TEXT, PRIMARY KEY(`courseId`))", + "fields": [ + { + "fieldPath": "courseId", + "columnName": "courseId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "auditAccessExpires", + "columnName": "auditAccessExpires", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "created", + "columnName": "created", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "mode", + "columnName": "mode", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "isActive", + "columnName": "isActive", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "course.id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "course.name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "course.number", + "columnName": "number", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "course.org", + "columnName": "org", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "course.start", + "columnName": "start", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "course.startDisplay", + "columnName": "startDisplay", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "course.startType", + "columnName": "startType", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "course.end", + "columnName": "end", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "course.dynamicUpgradeDeadline", + "columnName": "dynamicUpgradeDeadline", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "course.subscriptionId", + "columnName": "subscriptionId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "course.courseImage", + "columnName": "course_image_link", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "course.courseAbout", + "columnName": "courseAbout", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "course.courseUpdates", + "columnName": "courseUpdates", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "course.courseHandouts", + "columnName": "courseHandouts", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "course.discussionUrl", + "columnName": "discussionUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "course.videoOutline", + "columnName": "videoOutline", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "course.isSelfPaced", + "columnName": "isSelfPaced", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "course.coursewareAccess.hasAccess", + "columnName": "hasAccess", + "affinity": "INTEGER" + }, + { + "fieldPath": "course.coursewareAccess.errorCode", + "columnName": "errorCode", + "affinity": "TEXT" + }, + { + "fieldPath": "course.coursewareAccess.developerMessage", + "columnName": "developerMessage", + "affinity": "TEXT" + }, + { + "fieldPath": "course.coursewareAccess.userMessage", + "columnName": "userMessage", + "affinity": "TEXT" + }, + { + "fieldPath": "course.coursewareAccess.additionalContextUserMessage", + "columnName": "additionalContextUserMessage", + "affinity": "TEXT" + }, + { + "fieldPath": "course.coursewareAccess.userFragment", + "columnName": "userFragment", + "affinity": "TEXT" + }, + { + "fieldPath": "course.media.bannerImage", + "columnName": "bannerImage", + "affinity": "TEXT" + }, + { + "fieldPath": "course.media.courseImage", + "columnName": "courseImage", + "affinity": "TEXT" + }, + { + "fieldPath": "course.media.courseVideo", + "columnName": "courseVideo", + "affinity": "TEXT" + }, + { + "fieldPath": "course.media.image", + "columnName": "image", + "affinity": "TEXT" + }, + { + "fieldPath": "course.courseSharingUtmParameters.facebook", + "columnName": "facebook", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "course.courseSharingUtmParameters.twitter", + "columnName": "twitter", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "certificate.certificateURL", + "columnName": "certificateURL", + "affinity": "TEXT" + }, + { + "fieldPath": "progress.assignmentsCompleted", + "columnName": "assignments_completed", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "progress.totalAssignmentsCount", + "columnName": "total_assignments_count", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "courseStatus.lastVisitedModuleId", + "columnName": "lastVisitedModuleId", + "affinity": "TEXT" + }, + { + "fieldPath": "courseStatus.lastVisitedModulePath", + "columnName": "lastVisitedModulePath", + "affinity": "TEXT" + }, + { + "fieldPath": "courseStatus.lastVisitedBlockId", + "columnName": "lastVisitedBlockId", + "affinity": "TEXT" + }, + { + "fieldPath": "courseStatus.lastVisitedUnitDisplayName", + "columnName": "lastVisitedUnitDisplayName", + "affinity": "TEXT" + }, + { + "fieldPath": "courseAssignments.futureAssignments", + "columnName": "futureAssignments", + "affinity": "TEXT" + }, + { + "fieldPath": "courseAssignments.pastAssignments", + "columnName": "pastAssignments", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "courseId" + ] + } + }, + { + "tableName": "course_structure_table", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`root` TEXT NOT NULL, `id` TEXT NOT NULL, `blocks` TEXT NOT NULL, `name` TEXT NOT NULL, `number` TEXT NOT NULL, `org` TEXT NOT NULL, `start` TEXT, `startDisplay` TEXT NOT NULL, `startType` TEXT NOT NULL, `end` TEXT, `isSelfPaced` INTEGER NOT NULL, `hasAccess` INTEGER, `errorCode` TEXT, `developerMessage` TEXT, `userMessage` TEXT, `additionalContextUserMessage` TEXT, `userFragment` TEXT, `bannerImage` TEXT, `courseImage` TEXT, `courseVideo` TEXT, `image` TEXT, `certificateURL` TEXT, `assignments_completed` INTEGER NOT NULL, `total_assignments_count` INTEGER NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "root", + "columnName": "root", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "blocks", + "columnName": "blocks", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "number", + "columnName": "number", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "org", + "columnName": "org", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "start", + "columnName": "start", + "affinity": "TEXT" + }, + { + "fieldPath": "startDisplay", + "columnName": "startDisplay", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "startType", + "columnName": "startType", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "end", + "columnName": "end", + "affinity": "TEXT" + }, + { + "fieldPath": "isSelfPaced", + "columnName": "isSelfPaced", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "coursewareAccess.hasAccess", + "columnName": "hasAccess", + "affinity": "INTEGER" + }, + { + "fieldPath": "coursewareAccess.errorCode", + "columnName": "errorCode", + "affinity": "TEXT" + }, + { + "fieldPath": "coursewareAccess.developerMessage", + "columnName": "developerMessage", + "affinity": "TEXT" + }, + { + "fieldPath": "coursewareAccess.userMessage", + "columnName": "userMessage", + "affinity": "TEXT" + }, + { + "fieldPath": "coursewareAccess.additionalContextUserMessage", + "columnName": "additionalContextUserMessage", + "affinity": "TEXT" + }, + { + "fieldPath": "coursewareAccess.userFragment", + "columnName": "userFragment", + "affinity": "TEXT" + }, + { + "fieldPath": "media.bannerImage", + "columnName": "bannerImage", + "affinity": "TEXT" + }, + { + "fieldPath": "media.courseImage", + "columnName": "courseImage", + "affinity": "TEXT" + }, + { + "fieldPath": "media.courseVideo", + "columnName": "courseVideo", + "affinity": "TEXT" + }, + { + "fieldPath": "media.image", + "columnName": "image", + "affinity": "TEXT" + }, + { + "fieldPath": "certificate.certificateURL", + "columnName": "certificateURL", + "affinity": "TEXT" + }, + { + "fieldPath": "progress.assignmentsCompleted", + "columnName": "assignments_completed", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "progress.totalAssignmentsCount", + "columnName": "total_assignments_count", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + } + }, + { + "tableName": "download_model", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `title` TEXT NOT NULL, `courseId` TEXT NOT NULL, `size` INTEGER NOT NULL, `path` TEXT NOT NULL, `url` TEXT NOT NULL, `type` TEXT NOT NULL, `downloadedState` TEXT NOT NULL, `lastModified` TEXT, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "courseId", + "columnName": "courseId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "size", + "columnName": "size", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "path", + "columnName": "path", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "downloadedState", + "columnName": "downloadedState", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lastModified", + "columnName": "lastModified", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + } + }, + { + "tableName": "offline_x_block_progress_table", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `courseId` TEXT NOT NULL, `url` TEXT NOT NULL, `type` TEXT NOT NULL, `data` TEXT NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "blockId", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "courseId", + "columnName": "courseId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "jsonProgress.url", + "columnName": "url", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "jsonProgress.type", + "columnName": "type", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "jsonProgress.data", + "columnName": "data", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + } + }, + { + "tableName": "course_calendar_event_table", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`event_id` INTEGER NOT NULL, `course_id` TEXT NOT NULL, PRIMARY KEY(`event_id`))", + "fields": [ + { + "fieldPath": "eventId", + "columnName": "event_id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "courseId", + "columnName": "course_id", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "event_id" + ] + } + }, + { + "tableName": "course_calendar_state_table", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`course_id` TEXT NOT NULL, `checksum` INTEGER NOT NULL, `is_course_sync_enabled` INTEGER NOT NULL, PRIMARY KEY(`course_id`))", + "fields": [ + { + "fieldPath": "courseId", + "columnName": "course_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "checksum", + "columnName": "checksum", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isCourseSyncEnabled", + "columnName": "is_course_sync_enabled", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "course_id" + ] + } + }, + { + "tableName": "download_course_preview_table", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`course_id` TEXT NOT NULL, `course_name` TEXT, `course_image` TEXT, `total_size` INTEGER, PRIMARY KEY(`course_id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "course_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "course_name", + "affinity": "TEXT" + }, + { + "fieldPath": "image", + "columnName": "course_image", + "affinity": "TEXT" + }, + { + "fieldPath": "totalSize", + "columnName": "total_size", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "course_id" + ] + } + }, + { + "tableName": "course_enrollment_details_table", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `courseUpdates` TEXT NOT NULL, `courseHandouts` TEXT NOT NULL, `discussionUrl` TEXT NOT NULL, `hasUnmetPrerequisites` INTEGER NOT NULL, `isTooEarly` INTEGER NOT NULL, `isStaff` INTEGER NOT NULL, `auditAccessExpires` TEXT, `hasAccess` INTEGER, `errorCode` TEXT, `developerMessage` TEXT, `userMessage` TEXT, `additionalContextUserMessage` TEXT, `userFragment` TEXT, `certificateURL` TEXT, `created` TEXT, `mode` TEXT, `isActive` INTEGER NOT NULL, `upgradeDeadline` TEXT, `name` TEXT NOT NULL, `number` TEXT NOT NULL, `org` TEXT NOT NULL, `start` INTEGER, `startDisplay` TEXT NOT NULL, `startType` TEXT NOT NULL, `end` INTEGER, `isSelfPaced` INTEGER NOT NULL, `courseAbout` TEXT NOT NULL, `bannerImage` TEXT, `courseImage` TEXT, `courseVideo` TEXT, `image` TEXT, `facebook` TEXT NOT NULL, `twitter` TEXT NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "courseUpdates", + "columnName": "courseUpdates", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "courseHandouts", + "columnName": "courseHandouts", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "discussionUrl", + "columnName": "discussionUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "courseAccessDetails.hasUnmetPrerequisites", + "columnName": "hasUnmetPrerequisites", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "courseAccessDetails.isTooEarly", + "columnName": "isTooEarly", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "courseAccessDetails.isStaff", + "columnName": "isStaff", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "courseAccessDetails.auditAccessExpires", + "columnName": "auditAccessExpires", + "affinity": "TEXT" + }, + { + "fieldPath": "courseAccessDetails.coursewareAccess.hasAccess", + "columnName": "hasAccess", + "affinity": "INTEGER" + }, + { + "fieldPath": "courseAccessDetails.coursewareAccess.errorCode", + "columnName": "errorCode", + "affinity": "TEXT" + }, + { + "fieldPath": "courseAccessDetails.coursewareAccess.developerMessage", + "columnName": "developerMessage", + "affinity": "TEXT" + }, + { + "fieldPath": "courseAccessDetails.coursewareAccess.userMessage", + "columnName": "userMessage", + "affinity": "TEXT" + }, + { + "fieldPath": "courseAccessDetails.coursewareAccess.additionalContextUserMessage", + "columnName": "additionalContextUserMessage", + "affinity": "TEXT" + }, + { + "fieldPath": "courseAccessDetails.coursewareAccess.userFragment", + "columnName": "userFragment", + "affinity": "TEXT" + }, + { + "fieldPath": "certificate.certificateURL", + "columnName": "certificateURL", + "affinity": "TEXT" + }, + { + "fieldPath": "enrollmentDetails.created", + "columnName": "created", + "affinity": "TEXT" + }, + { + "fieldPath": "enrollmentDetails.mode", + "columnName": "mode", + "affinity": "TEXT" + }, + { + "fieldPath": "enrollmentDetails.isActive", + "columnName": "isActive", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "enrollmentDetails.upgradeDeadline", + "columnName": "upgradeDeadline", + "affinity": "TEXT" + }, + { + "fieldPath": "courseInfoOverview.name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "courseInfoOverview.number", + "columnName": "number", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "courseInfoOverview.org", + "columnName": "org", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "courseInfoOverview.start", + "columnName": "start", + "affinity": "INTEGER" + }, + { + "fieldPath": "courseInfoOverview.startDisplay", + "columnName": "startDisplay", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "courseInfoOverview.startType", + "columnName": "startType", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "courseInfoOverview.end", + "columnName": "end", + "affinity": "INTEGER" + }, + { + "fieldPath": "courseInfoOverview.isSelfPaced", + "columnName": "isSelfPaced", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "courseInfoOverview.courseAbout", + "columnName": "courseAbout", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "courseInfoOverview.media.bannerImage", + "columnName": "bannerImage", + "affinity": "TEXT" + }, + { + "fieldPath": "courseInfoOverview.media.courseImage", + "columnName": "courseImage", + "affinity": "TEXT" + }, + { + "fieldPath": "courseInfoOverview.media.courseVideo", + "columnName": "courseVideo", + "affinity": "TEXT" + }, + { + "fieldPath": "courseInfoOverview.media.image", + "columnName": "image", + "affinity": "TEXT" + }, + { + "fieldPath": "courseInfoOverview.courseSharingUtmParameters.facebook", + "columnName": "facebook", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "courseInfoOverview.courseSharingUtmParameters.twitter", + "columnName": "twitter", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + } + }, + { + "tableName": "video_progress_table", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`block_id` TEXT NOT NULL, `video_url` TEXT NOT NULL, `video_time` INTEGER, `duration` INTEGER, PRIMARY KEY(`block_id`))", + "fields": [ + { + "fieldPath": "blockId", + "columnName": "block_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "videoUrl", + "columnName": "video_url", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "videoTime", + "columnName": "video_time", + "affinity": "INTEGER" + }, + { + "fieldPath": "duration", + "columnName": "duration", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "block_id" + ] + } + }, + { + "tableName": "course_progress_table", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`courseId` TEXT NOT NULL, `verifiedMode` TEXT NOT NULL, `accessExpiration` TEXT NOT NULL, `creditCourseRequirements` TEXT NOT NULL, `end` TEXT NOT NULL, `enrollmentMode` TEXT NOT NULL, `hasScheduledContent` INTEGER NOT NULL, `sectionScores` TEXT NOT NULL, `studioUrl` TEXT NOT NULL, `username` TEXT NOT NULL, `userHasPassingGrade` INTEGER NOT NULL, `disableProgressGraph` INTEGER NOT NULL, `certificate_certStatus` TEXT, `certificate_certWebViewUrl` TEXT, `certificate_downloadUrl` TEXT, `certificate_certificateAvailableDate` TEXT, `completion_completeCount` INTEGER, `completion_incompleteCount` INTEGER, `completion_lockedCount` INTEGER, `grade_letterGrade` TEXT, `grade_percent` REAL, `grade_isPassing` INTEGER, `grading_assignmentPolicies` TEXT, `grading_gradeRange` TEXT, `grading_assignmentColors` TEXT, `verification_link` TEXT, `verification_status` TEXT, `verification_statusDate` TEXT, PRIMARY KEY(`courseId`))", + "fields": [ + { + "fieldPath": "courseId", + "columnName": "courseId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "verifiedMode", + "columnName": "verifiedMode", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "accessExpiration", + "columnName": "accessExpiration", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "creditCourseRequirements", + "columnName": "creditCourseRequirements", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "end", + "columnName": "end", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "enrollmentMode", + "columnName": "enrollmentMode", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "hasScheduledContent", + "columnName": "hasScheduledContent", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "sectionScores", + "columnName": "sectionScores", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "studioUrl", + "columnName": "studioUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "username", + "columnName": "username", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "userHasPassingGrade", + "columnName": "userHasPassingGrade", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "disableProgressGraph", + "columnName": "disableProgressGraph", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "certificateData.certStatus", + "columnName": "certificate_certStatus", + "affinity": "TEXT" + }, + { + "fieldPath": "certificateData.certWebViewUrl", + "columnName": "certificate_certWebViewUrl", + "affinity": "TEXT" + }, + { + "fieldPath": "certificateData.downloadUrl", + "columnName": "certificate_downloadUrl", + "affinity": "TEXT" + }, + { + "fieldPath": "certificateData.certificateAvailableDate", + "columnName": "certificate_certificateAvailableDate", + "affinity": "TEXT" + }, + { + "fieldPath": "completionSummary.completeCount", + "columnName": "completion_completeCount", + "affinity": "INTEGER" + }, + { + "fieldPath": "completionSummary.incompleteCount", + "columnName": "completion_incompleteCount", + "affinity": "INTEGER" + }, + { + "fieldPath": "completionSummary.lockedCount", + "columnName": "completion_lockedCount", + "affinity": "INTEGER" + }, + { + "fieldPath": "courseGrade.letterGrade", + "columnName": "grade_letterGrade", + "affinity": "TEXT" + }, + { + "fieldPath": "courseGrade.percent", + "columnName": "grade_percent", + "affinity": "REAL" + }, + { + "fieldPath": "courseGrade.isPassing", + "columnName": "grade_isPassing", + "affinity": "INTEGER" + }, + { + "fieldPath": "gradingPolicy.assignmentPolicies", + "columnName": "grading_assignmentPolicies", + "affinity": "TEXT" + }, + { + "fieldPath": "gradingPolicy.gradeRange", + "columnName": "grading_gradeRange", + "affinity": "TEXT" + }, + { + "fieldPath": "gradingPolicy.assignmentColors", + "columnName": "grading_assignmentColors", + "affinity": "TEXT" + }, + { + "fieldPath": "verificationData.link", + "columnName": "verification_link", + "affinity": "TEXT" + }, + { + "fieldPath": "verificationData.status", + "columnName": "verification_status", + "affinity": "TEXT" + }, + { + "fieldPath": "verificationData.statusDate", + "columnName": "verification_statusDate", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "courseId" + ] + } + } + ], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '09f6fc49a2f7a494d27f3290d7bae350')" + ] + } +} \ No newline at end of file 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 b4633cc27..cdb240387 100644 --- a/app/src/main/java/org/openedx/app/di/AppModule.kt +++ b/app/src/main/java/org/openedx/app/di/AppModule.kt @@ -35,6 +35,7 @@ import org.openedx.core.data.model.CourseEnrollments import org.openedx.core.data.storage.CalendarPreferences import org.openedx.core.data.storage.CorePreferences import org.openedx.core.data.storage.InAppReviewPreferences +import org.openedx.core.domain.helper.VideoPreviewHelper import org.openedx.core.module.DownloadWorkerController import org.openedx.core.module.TranscriptManager import org.openedx.core.module.download.DownloadHelper @@ -216,6 +217,7 @@ val appModule = module { factory { MicrosoftAuthHelper() } factory { BrowserAuthHelper(get()) } factory { OAuthHelper(get(), get(), get()) } + factory { VideoPreviewHelper(get(), get()) } factory { FileUtil(get(), get().getString(R.string.app_name)) } single { DownloadHelper(get(), 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 07caf8037..1d3604050 100644 --- a/app/src/main/java/org/openedx/app/di/ScreenModule.kt +++ b/app/src/main/java/org/openedx/app/di/ScreenModule.kt @@ -29,6 +29,7 @@ import org.openedx.course.presentation.outline.CourseContentAllViewModel import org.openedx.course.presentation.progress.CourseProgressViewModel import org.openedx.course.presentation.section.CourseSectionViewModel import org.openedx.course.presentation.unit.container.CourseUnitContainerViewModel +import org.openedx.course.presentation.unit.container.CourseViewMode import org.openedx.course.presentation.unit.html.HtmlUnitViewModel import org.openedx.course.presentation.unit.video.BaseVideoViewModel import org.openedx.course.presentation.unit.video.EncodedVideoUnitViewModel @@ -340,10 +341,12 @@ val screenModule = module { get(), ) } - viewModel { (courseId: String, unitId: String) -> + viewModel { (courseId: String, unitId: String, mode: CourseViewMode) -> CourseUnitContainerViewModel( courseId, unitId, + mode, + get(), get(), get(), get(), diff --git a/app/src/main/java/org/openedx/app/room/AppDatabase.kt b/app/src/main/java/org/openedx/app/room/AppDatabase.kt index fd0b0069f..b2f275bb3 100644 --- a/app/src/main/java/org/openedx/app/room/AppDatabase.kt +++ b/app/src/main/java/org/openedx/app/room/AppDatabase.kt @@ -23,7 +23,7 @@ import org.openedx.discovery.data.converter.DiscoveryConverter import org.openedx.discovery.data.model.room.CourseEntity import org.openedx.discovery.data.storage.DiscoveryDao -const val DATABASE_VERSION = 4 +const val DATABASE_VERSION = 5 const val DATABASE_NAME = "OpenEdX_db" @Suppress("MagicNumber") @@ -44,7 +44,8 @@ const val DATABASE_NAME = "OpenEdX_db" autoMigrations = [ AutoMigration(1, 2), AutoMigration(2, 3), - AutoMigration(3, DATABASE_VERSION), + AutoMigration(3, 4), + AutoMigration(4, DATABASE_VERSION), ], version = DATABASE_VERSION ) diff --git a/build.gradle b/build.gradle index b59516fd2..674a1057f 100644 --- a/build.gradle +++ b/build.gradle @@ -20,7 +20,7 @@ buildscript { // Library versions media3_version = "1.8.0" - youtubeplayer_version = "11.1.0" + youtubeplayer_version = "13.0.0" firebase_version = "33.0.0" jsoup_version = '1.21.2' in_app_review = '2.0.2' diff --git a/core/src/main/java/org/openedx/core/data/model/room/CourseEnrollmentDetailsEntity.kt b/core/src/main/java/org/openedx/core/data/model/room/CourseEnrollmentDetailsEntity.kt index cc5d55278..cc80a0438 100644 --- a/core/src/main/java/org/openedx/core/data/model/room/CourseEnrollmentDetailsEntity.kt +++ b/core/src/main/java/org/openedx/core/data/model/room/CourseEnrollmentDetailsEntity.kt @@ -51,13 +51,13 @@ data class CourseInfoOverviewDb( val number: String, @ColumnInfo("org") val org: String, - @Embedded + @ColumnInfo("start") val start: Date?, @ColumnInfo("startDisplay") val startDisplay: String, @ColumnInfo("startType") val startType: String, - @Embedded + @ColumnInfo("end") val end: Date?, @ColumnInfo("isSelfPaced") val isSelfPaced: Boolean, diff --git a/core/src/main/java/org/openedx/core/domain/helper/VideoPreviewHelper.kt b/core/src/main/java/org/openedx/core/domain/helper/VideoPreviewHelper.kt new file mode 100644 index 000000000..6914cb78c --- /dev/null +++ b/core/src/main/java/org/openedx/core/domain/helper/VideoPreviewHelper.kt @@ -0,0 +1,62 @@ +package org.openedx.core.domain.helper + +import android.content.Context +import org.openedx.core.domain.model.Block +import org.openedx.core.system.connection.NetworkConnection +import org.openedx.core.utils.VideoPreview + +/** + * Helper class for handling video preview generation. + * This class encapsulates the logic for getting video previews from blocks, + * avoiding the need to inject Context directly into ViewModels. + */ +class VideoPreviewHelper( + private val context: Context, + private val networkConnection: NetworkConnection +) { + + /** + * Gets video preview for a single block + * @param block The block to get video preview for + * @param offlineUrl Optional offline URL for the video + * @return VideoPreview object or null if no preview available + */ + fun getVideoPreview(block: Block, offlineUrl: String? = null): VideoPreview? { + return block.getVideoPreview( + context = context, + isOnline = networkConnection.isOnline(), + offlineUrl = offlineUrl + ) + } + + /** + * Gets video previews for multiple blocks + * @param blocks List of blocks to get video previews for + * @param offlineUrls Optional map of block IDs to offline URLs + * @return Map of block IDs to VideoPreview objects + */ + fun getVideoPreviews( + blocks: List, + offlineUrls: Map? = null + ): Map { + return blocks.associate { block -> + val offlineUrl = offlineUrls?.get(block.id) + block.id to getVideoPreview(block, offlineUrl) + } + } + + /** + * Gets video preview for a single block with a specific offline URL + * @param blockId The ID of the block + * @param block The block to get video preview for + * @param offlineUrl Optional offline URL for the video + * @return Pair of block ID and VideoPreview object or null + */ + fun getVideoPreviewWithId( + blockId: String, + block: Block, + offlineUrl: String? = null + ): Pair { + return blockId to getVideoPreview(block, offlineUrl) + } +} 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 089acc04f..f6e39aef3 100644 --- a/core/src/openedx/org/openedx/core/ui/theme/Colors.kt +++ b/core/src/openedx/org/openedx/core/ui/theme/Colors.kt @@ -16,25 +16,25 @@ 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_info_variant = light_primary 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_accent = light_primary 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_text_hyper_link = Color(0xFF3C68FF) +val light_text_hyper_link = light_primary -val light_primary_button_background = Color(0xFF3C68FF) +val light_primary_button_background = light_primary val light_primary_button_border = Color(0xFF97A5BB) val light_primary_button_text = Color.White -val light_primary_button_bordered_text = Color(0xFF3C68FF) +val light_primary_button_bordered_text = light_primary val light_secondary_button_background = light_primary_button_background val light_secondary_button_text = light_primary_button_text @@ -102,12 +102,12 @@ 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_text_hyper_link = Color(0xFF5478F9) +val dark_text_hyper_link = dark_primary -val dark_primary_button_background = Color(0xFF5478F9) +val dark_primary_button_background = dark_primary val dark_primary_button_text = Color.White val dark_primary_button_border = Color(0xFF4E5A70) -val dark_primary_button_bordered_text = Color(0xFF5478F9) +val dark_primary_button_bordered_text = dark_primary val dark_secondary_button_background = dark_primary_button_background val dark_secondary_button_text = dark_primary_button_text diff --git a/course/build.gradle b/course/build.gradle index 7ae8809ed..227b52a0c 100644 --- a/course/build.gradle +++ b/course/build.gradle @@ -62,6 +62,7 @@ dependencies { implementation project(path: ':core') implementation project(path: ':discussion') implementation "com.pierfrancescosoffritti.androidyoutubeplayer:core:$youtubeplayer_version" + implementation "com.pierfrancescosoffritti.androidyoutubeplayer:custom-ui:$youtubeplayer_version" // Media3 implementation "androidx.media3:media3-exoplayer:$media3_version" @@ -76,4 +77,4 @@ dependencies { testImplementation "io.mockk:mockk:$mockk_version" testImplementation "io.mockk:mockk-android:$mockk_version" testImplementation "androidx.arch.core:core-testing:$android_arch_version" -} \ No newline at end of file +} 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 c59b69638..b49a806e6 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 @@ -7,10 +7,20 @@ import org.openedx.core.data.model.room.BlockDb import org.openedx.core.data.model.room.GradingPolicyDb import org.openedx.core.data.model.room.SectionScoreDb import org.openedx.core.data.model.room.discovery.CourseDateBlockDb -import org.openedx.foundation.extension.genericType +import java.util.Date class CourseConverter { + @TypeConverter + fun fromDate(value: Date?): Long? { + return value?.time + } + + @TypeConverter + fun toDate(value: Long?): Date? { + return value?.let { Date(it) } + } + @TypeConverter fun fromListOfString(value: List): String { val json = Gson().toJson(value) @@ -19,7 +29,7 @@ class CourseConverter { @TypeConverter fun toListOfString(value: String): List { - val type = genericType>() + val type = object : TypeToken>() {}.type return Gson().fromJson(value, type) } @@ -31,7 +41,7 @@ class CourseConverter { @TypeConverter fun toListOfBlockDbEntity(value: String): List { - val type = genericType>() + val type = object : TypeToken>() {}.type return Gson().fromJson(value, type) } @@ -43,7 +53,7 @@ class CourseConverter { @TypeConverter fun toListOfCourseDateBlockDb(value: String): List { - val type = genericType>() + val type = object : TypeToken>() {}.type return Gson().fromJson(value, type) } diff --git a/course/src/main/java/org/openedx/course/presentation/home/CourseHomeViewModel.kt b/course/src/main/java/org/openedx/course/presentation/home/CourseHomeViewModel.kt index a5f42db5c..7d1381505 100644 --- a/course/src/main/java/org/openedx/course/presentation/home/CourseHomeViewModel.kt +++ b/course/src/main/java/org/openedx/course/presentation/home/CourseHomeViewModel.kt @@ -1,6 +1,5 @@ package org.openedx.course.presentation.home -import android.content.Context import androidx.fragment.app.FragmentManager import androidx.lifecycle.viewModelScope import kotlinx.coroutines.Dispatchers @@ -18,6 +17,7 @@ import org.openedx.core.BlockType import org.openedx.core.R import org.openedx.core.config.Config import org.openedx.core.data.storage.CorePreferences +import org.openedx.core.domain.helper.VideoPreviewHelper import org.openedx.core.domain.model.Block import org.openedx.core.domain.model.CourseComponentStatus import org.openedx.core.domain.model.CourseDatesBannerInfo @@ -54,7 +54,6 @@ import org.openedx.course.R as courseR class CourseHomeViewModel( val courseId: String, private val courseTitle: String, - private val context: Context, private val config: Config, private val interactor: CourseInteractor, private val resourceManager: ResourceManager, @@ -65,6 +64,7 @@ class CourseHomeViewModel( private val downloadDialogManager: DownloadDialogManager, private val fileUtil: FileUtil, val courseRouter: CourseRouter, + private val videoPreviewHelper: VideoPreviewHelper, coreAnalytics: CoreAnalytics, downloadDao: DownloadDao, workerController: DownloadWorkerController, @@ -270,11 +270,9 @@ class CourseHomeViewModel( private fun getVideoPreview(videoBlock: Block?) { viewModelScope.launch(Dispatchers.IO) { - val videoPreview = videoBlock?.getVideoPreview( - context, - networkConnection.isOnline(), - null - ) + val videoPreview = videoBlock?.let { block -> + videoPreviewHelper.getVideoPreview(block, null) + } _uiState.value = (_uiState.value as? CourseHomeUIState.CourseData) ?.copy( videoPreview = videoPreview diff --git a/course/src/main/java/org/openedx/course/presentation/home/VideosHomePagerCardContent.kt b/course/src/main/java/org/openedx/course/presentation/home/VideosHomePagerCardContent.kt index ecbeec99b..50c15500c 100644 --- a/course/src/main/java/org/openedx/course/presentation/home/VideosHomePagerCardContent.kt +++ b/course/src/main/java/org/openedx/course/presentation/home/VideosHomePagerCardContent.kt @@ -1,6 +1,5 @@ package org.openedx.course.presentation.home -import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer @@ -10,7 +9,6 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.shape.CircleShape -import androidx.compose.material.Card import androidx.compose.material.Icon import androidx.compose.material.LinearProgressIndicator import androidx.compose.material.MaterialTheme @@ -26,9 +24,7 @@ import androidx.compose.ui.semantics.semantics import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import org.openedx.core.domain.model.Block -import org.openedx.core.extension.getUnitChapter import org.openedx.core.ui.theme.appColors -import org.openedx.core.ui.theme.appShapes import org.openedx.core.ui.theme.appTypography import org.openedx.course.R import org.openedx.course.presentation.contenttab.CourseContentVideoEmptyState @@ -131,42 +127,21 @@ fun VideosHomePagerCardContent( Spacer(modifier = Modifier.height(8.dp)) // Video card using CourseVideoItem - Card( + CourseVideoItem( modifier = Modifier - .fillMaxWidth(), - backgroundColor = MaterialTheme.appColors.cardViewBackground, - shape = MaterialTheme.appShapes.videoPreviewShape, - elevation = 0.dp, - border = BorderStroke( - width = 1.dp, - color = MaterialTheme.appColors.cardViewBorder - ) - ) { - Column { - CourseVideoItem( - modifier = Modifier - .fillMaxWidth() - .height(180.dp), - videoBlock = firstIncompleteVideo, - preview = uiState.videoPreview, - progress = videoProgress, - onClick = { - onVideoClick(firstIncompleteVideo) - }, - titleStyle = MaterialTheme.appTypography.titleMedium, - contentModifier = Modifier - .padding(horizontal = 16.dp, vertical = 8.dp), - progressModifier = Modifier.height(8.dp), - ) - Text( - modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp), - text = uiState.courseStructure.blockData - .getUnitChapter(firstIncompleteVideo.id)?.displayName ?: "", - style = MaterialTheme.appTypography.labelMedium, - color = MaterialTheme.appColors.textPrimary, - ) - } - } + .fillMaxWidth() + .height(180.dp), + videoBlock = firstIncompleteVideo, + preview = uiState.videoPreview, + progress = videoProgress, + onClick = { + onVideoClick(firstIncompleteVideo) + }, + titleStyle = MaterialTheme.appTypography.titleMedium, + contentModifier = Modifier + .padding(horizontal = 16.dp, vertical = 8.dp), + progressModifier = Modifier.height(8.dp), + ) } else { CaughtUpMessage( message = stringResource(R.string.course_videos_caught_up) 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 82c28cb4f..19d3bb4b5 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 @@ -51,6 +51,7 @@ import androidx.compose.material.Snackbar import androidx.compose.material.Surface import androidx.compose.material.Text import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.automirrored.filled.ArrowForward import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight import androidx.compose.material.icons.filled.Close @@ -338,6 +339,7 @@ fun NavigationUnitsButtons( nextButtonText: String, hasPrevBlock: Boolean, hasNextBlock: Boolean, + showFinishButton: Boolean = true, isVerticalNavigation: Boolean, onPrevClick: () -> Unit, onNextClick: () -> Unit, @@ -375,7 +377,7 @@ fun NavigationUnitsButtons( colors = ButtonDefaults.outlinedButtonColors( backgroundColor = MaterialTheme.appColors.background ), - border = BorderStroke(1.dp, MaterialTheme.appColors.primaryButtonBorder), + border = BorderStroke(1.dp, MaterialTheme.appColors.textAccent), elevation = null, shape = MaterialTheme.appShapes.navigationButtonShape, onClick = onPrevClick, @@ -384,48 +386,66 @@ fun NavigationUnitsButtons( verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.Center ) { + if (!isVerticalNavigation) { + Icon( + imageVector = Icons.AutoMirrored.Filled.ArrowBack, + contentDescription = null, + tint = MaterialTheme.appColors.textAccent + ) + Spacer(Modifier.width(8.dp)) + } Text( text = stringResource(R.string.course_navigation_prev), - color = MaterialTheme.appColors.primary, + color = MaterialTheme.appColors.textAccent, style = MaterialTheme.appTypography.labelLarge ) - Spacer(Modifier.width(8.dp)) - Icon( - modifier = Modifier.rotate(if (isVerticalNavigation) 0f else -90f), - painter = painterResource(id = coreR.drawable.core_ic_up), - contentDescription = null, - tint = MaterialTheme.appColors.primary - ) + if (isVerticalNavigation) { + Spacer(Modifier.width(8.dp)) + Icon( + painter = painterResource(id = coreR.drawable.core_ic_up), + contentDescription = null, + tint = MaterialTheme.appColors.textAccent + ) + } } } Spacer(Modifier.width(16.dp)) } - Button( - modifier = Modifier - .height(42.dp), - colors = ButtonDefaults.buttonColors( - backgroundColor = MaterialTheme.appColors.primaryButtonBackground - ), - elevation = null, - shape = MaterialTheme.appShapes.navigationButtonShape, - onClick = onNextClick - ) { - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.Center + if (hasNextBlock || showFinishButton) { + Button( + modifier = Modifier + .height(42.dp), + colors = ButtonDefaults.buttonColors( + backgroundColor = MaterialTheme.appColors.primaryButtonBackground + ), + elevation = null, + shape = MaterialTheme.appShapes.navigationButtonShape, + onClick = onNextClick ) { - Text( - text = nextButtonText, - color = MaterialTheme.appColors.primaryButtonText, - style = MaterialTheme.appTypography.labelLarge - ) - Spacer(Modifier.width(8.dp)) - Icon( - modifier = Modifier.rotate(if (isVerticalNavigation || !hasNextBlock) 0f else -90f), - painter = nextButtonIcon, - contentDescription = null, - tint = MaterialTheme.appColors.primaryButtonText - ) + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.Center + ) { + Text( + text = nextButtonText, + color = MaterialTheme.appColors.primaryButtonText, + style = MaterialTheme.appTypography.labelLarge + ) + Spacer(Modifier.width(8.dp)) + if (isVerticalNavigation || !hasNextBlock) { + Icon( + painter = nextButtonIcon, + contentDescription = null, + tint = MaterialTheme.appColors.primaryButtonText + ) + } else { + Icon( + imageVector = Icons.AutoMirrored.Filled.ArrowForward, + contentDescription = null, + tint = MaterialTheme.appColors.primaryButtonText + ) + } + } } } } @@ -700,20 +720,23 @@ fun CourseVideoItem( titleStyle: TextStyle = MaterialTheme.appTypography.bodySmall, contentModifier: Modifier = Modifier.padding(8.dp), progressModifier: Modifier = Modifier.height(4.dp), + playButtonSize: Dp = 32.dp, + borderColor: Color? = null, + borderWidth: Dp = 3.dp, ) { + val borderColor = borderColor ?: if (videoBlock.isCompleted()) { + MaterialTheme.appColors.successGreen + } else { + Color.Transparent + } Box( modifier = modifier - .let { - if (videoBlock.isCompleted()) { - it.border( - width = 3.dp, - color = MaterialTheme.appColors.successGreen, - shape = MaterialTheme.appShapes.videoPreviewShape - ) - } else { - it - } - } + .clip(MaterialTheme.appShapes.videoPreviewShape) + .border( + width = borderWidth, + color = borderColor, + shape = MaterialTheme.appShapes.videoPreviewShape + ) .clickable { onClick() } ) { AsyncImage( @@ -748,7 +771,7 @@ fun CourseVideoItem( ) { Image( modifier = Modifier - .size(32.dp) + .size(playButtonSize) .align(Alignment.Center), painter = painterResource(id = R.drawable.course_video_play_button), contentDescription = null, @@ -761,7 +784,7 @@ fun CourseVideoItem( style = titleStyle, modifier = Modifier .align(Alignment.TopStart), - maxLines = 2, + maxLines = 1, overflow = TextOverflow.Ellipsis ) diff --git a/course/src/main/java/org/openedx/course/presentation/unit/container/CourseUnitContainerFragment.kt b/course/src/main/java/org/openedx/course/presentation/unit/container/CourseUnitContainerFragment.kt index 4c4a136ec..b7131b993 100644 --- a/course/src/main/java/org/openedx/course/presentation/unit/container/CourseUnitContainerFragment.kt +++ b/course/src/main/java/org/openedx/course/presentation/unit/container/CourseUnitContainerFragment.kt @@ -6,9 +6,22 @@ import android.view.LayoutInflater import android.view.View import android.view.ViewGroup import androidx.activity.OnBackPressedCallback +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +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.width +import androidx.compose.foundation.lazy.LazyRow +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.material.Divider import androidx.compose.material.MaterialTheme +import androidx.compose.material.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.livedata.observeAsState @@ -16,6 +29,7 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier +import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.constraintlayout.widget.ConstraintLayout import androidx.core.os.bundleOf @@ -31,15 +45,18 @@ import org.koin.android.ext.android.inject import org.koin.androidx.viewmodel.ext.android.viewModel import org.koin.core.parameter.parametersOf import org.openedx.core.BlockType +import org.openedx.core.domain.model.Block import org.openedx.core.presentation.global.InsetHolder import org.openedx.core.ui.theme.OpenEdXTheme import org.openedx.core.ui.theme.appColors +import org.openedx.core.ui.theme.appTypography import org.openedx.course.R import org.openedx.course.databinding.FragmentCourseUnitContainerBinding import org.openedx.course.presentation.ChapterEndFragmentDialog import org.openedx.course.presentation.CourseRouter import org.openedx.course.presentation.DialogListener import org.openedx.course.presentation.ui.CourseUnitToolbar +import org.openedx.course.presentation.ui.CourseVideoItem import org.openedx.course.presentation.ui.HorizontalPageIndicator import org.openedx.course.presentation.ui.NavigationUnitsButtons import org.openedx.course.presentation.ui.SubSectionUnitsList @@ -56,7 +73,8 @@ class CourseUnitContainerFragment : Fragment(R.layout.fragment_course_unit_conta private val viewModel by viewModel { parametersOf( requireArguments().getString(ARG_COURSE_ID, ""), - requireArguments().getString(UNIT_ID, "") + requireArguments().getString(UNIT_ID, ""), + requireArguments().serializable(ARG_MODE) ) } @@ -95,7 +113,7 @@ class CourseUnitContainerFragment : Fragment(R.layout.fragment_course_unit_conta fm = requireActivity().supportFragmentManager, courseId = viewModel.courseId, unitId = it.id, - mode = requireArguments().serializable(ARG_MODE)!! + mode = viewModel.mode ) } } @@ -132,7 +150,7 @@ class CourseUnitContainerFragment : Fragment(R.layout.fragment_course_unit_conta super.onCreate(savedInstanceState) lifecycle.addObserver(viewModel) componentId = requireArguments().getString(ARG_COMPONENT_ID, "") - viewModel.loadBlocks(requireArguments().serializable(ARG_MODE)!!, componentId) + viewModel.loadBlocks(componentId) viewModel.courseUnitContainerShowedEvent() } @@ -156,6 +174,7 @@ class CourseUnitContainerFragment : Fragment(R.layout.fragment_course_unit_conta setupProgressIndicators() setupBackButton() setupSubSectionUnits() + setupVideoList() checkUnitsListShown() setupChapterEndDialogListener() } @@ -209,7 +228,7 @@ class CourseUnitContainerFragment : Fragment(R.layout.fragment_course_unit_conta blocks = descendantsBlocks, selectedPage = index, completedAndSelectedColor = - MaterialTheme.appColors.componentHorizontalProgressCompletedAndSelected, + MaterialTheme.appColors.componentHorizontalProgressCompletedAndSelected, completedColor = MaterialTheme.appColors.componentHorizontalProgressCompleted, selectedColor = MaterialTheme.appColors.componentHorizontalProgressSelected, defaultColor = MaterialTheme.appColors.componentHorizontalProgressDefault @@ -293,7 +312,7 @@ class CourseUnitContainerFragment : Fragment(R.layout.fragment_course_unit_conta fm = requireActivity().supportFragmentManager, courseId = viewModel.courseId, unitId = unit.id, - mode = requireArguments().serializable(ARG_MODE)!! + mode = viewModel.mode ) } else { handleUnitsClick() @@ -307,6 +326,41 @@ class CourseUnitContainerFragment : Fragment(R.layout.fragment_course_unit_conta } } + private fun setupVideoList() { + binding.videoList?.setContent { + OpenEdXTheme { + Column { + VideoList( + onVideoClick = { block -> + val currentBlock = viewModel.currentBlock.value + if (currentBlock?.id != block.id) { + viewModel.setSelectedVideoBlock(block) + updateViewPagerAdapter() + val blockIndex = + viewModel.getUnitBlocks().indexOfFirst { it.id == block.id } + if (blockIndex != -1) { + binding.viewPager.currentItem = blockIndex + } + } + } + ) + Spacer(modifier = Modifier.height(8.dp)) + Divider() + if (viewModel.mode == CourseViewMode.VIDEOS) { + Spacer(modifier = Modifier.height(16.dp)) + HierarchyPathText() + } + Spacer(modifier = Modifier.height(8.dp)) + } + } + } + } + + private fun updateViewPagerAdapter() { + adapter = CourseUnitContainerAdapter(this, viewModel.getUnitBlocks(), viewModel) + binding.viewPager.adapter = adapter + } + private fun checkUnitsListShown() { if (viewModel.unitsListShowed.value == true) { handleUnitsClick() @@ -434,42 +488,173 @@ class CourseUnitContainerFragment : Fragment(R.layout.fragment_course_unit_conta @Composable private fun NavigationBar() { - OpenEdXTheme { - var nextButtonText by rememberSaveable { - mutableStateOf(viewModel.nextButtonText) - } - var hasNextBlock by rememberSaveable { - mutableStateOf(viewModel.hasNextBlock) - } - var hasPrevBlock by rememberSaveable { - mutableStateOf(viewModel.hasNextBlock) + if (viewModel.mode == CourseViewMode.VIDEOS) { + OpenEdXTheme { + val videoBlocks by viewModel.videoList.collectAsState() + val currentBlock by viewModel.currentBlock.collectAsState() + val hasNextBlock = videoBlocks.lastOrNull()?.id != currentBlock?.id + val nextButtonText = if (hasNextBlock) { + getString(R.string.course_navigation_next) + } else { + getString(R.string.course_navigation_finish) + } + NavigationUnitsButtons( + hasPrevBlock = videoBlocks.firstOrNull()?.id != currentBlock?.id, + nextButtonText = nextButtonText, + hasNextBlock = hasNextBlock, + isVerticalNavigation = false, + showFinishButton = false, + onPrevClick = { + if (!restrictDoubleClick()) { + val currentIndex = + videoBlocks.indexOfFirst { it.id == currentBlock?.id } + if (currentIndex > 0) { + val target = videoBlocks[currentIndex - 1] + viewModel.setSelectedVideoBlock(target) + updateViewPagerAdapter() + val blockIndex = + viewModel.getUnitBlocks().indexOfFirst { it.id == target.id } + if (blockIndex != -1) { + binding.viewPager.setCurrentItem(blockIndex, true) + } + } + } + }, + onNextClick = { + if (!restrictDoubleClick()) { + val currentIndex = + videoBlocks.indexOfFirst { it.id == currentBlock?.id } + if (currentIndex != -1 && currentIndex < videoBlocks.lastIndex) { + val target = videoBlocks[currentIndex + 1] + viewModel.setSelectedVideoBlock(target) + updateViewPagerAdapter() + val blockIndex = + viewModel.getUnitBlocks().indexOfFirst { it.id == target.id } + if (blockIndex != -1) { + binding.viewPager.setCurrentItem(blockIndex, true) + } + } + } + } + ) } + } else { + OpenEdXTheme { + var nextButtonText by rememberSaveable { + mutableStateOf(viewModel.nextButtonText) + } + var hasNextBlock by rememberSaveable { + mutableStateOf(viewModel.hasNextBlock) + } + var hasPrevBlock by rememberSaveable { + mutableStateOf(viewModel.hasNextBlock) + } + + updateNavigationButtons { next, hasPrev, hasNext -> + nextButtonText = next + hasPrevBlock = hasPrev + hasNextBlock = hasNext + } - updateNavigationButtons { next, hasPrev, hasNext -> - nextButtonText = next - hasPrevBlock = hasPrev - hasNextBlock = hasNext + NavigationUnitsButtons( + hasPrevBlock = hasPrevBlock, + nextButtonText = nextButtonText, + hasNextBlock = hasNextBlock, + isVerticalNavigation = !viewModel.isCourseUnitProgressEnabled, + onPrevClick = { + handlePrevClick { next, hasPrev, hasNext -> + nextButtonText = next + hasPrevBlock = hasPrev + hasNextBlock = hasNext + } + }, + onNextClick = { + handleNextClick { next, hasPrev, hasNext -> + nextButtonText = next + hasPrevBlock = hasPrev + hasNextBlock = hasNext + } + } + ) } + } + } + + @Composable + private fun VideoList( + onVideoClick: (Block) -> Unit + ) { + val videoBlocks by viewModel.videoList.collectAsState() + val videoPreview by viewModel.videoPreview.collectAsState() + val videoProgress by viewModel.videoProgress.collectAsState() + val currentBlock by viewModel.currentBlock.collectAsState() + val rowState = rememberLazyListState() + + LaunchedEffect(currentBlock) { + rowState.animateScrollToItem(videoBlocks.indexOf(currentBlock)) + } - NavigationUnitsButtons( - hasPrevBlock = hasPrevBlock, - nextButtonText = nextButtonText, - hasNextBlock = hasNextBlock, - isVerticalNavigation = !viewModel.isCourseUnitProgressEnabled, - onPrevClick = { - handlePrevClick { next, hasPrev, hasNext -> - nextButtonText = next - hasPrevBlock = hasPrev - hasNextBlock = hasNext + if (videoBlocks.isNotEmpty()) { + LazyRow( + state = rowState, + horizontalArrangement = Arrangement.spacedBy(8.dp), + contentPadding = PaddingValues(horizontal = 16.dp, vertical = 8.dp) + ) { + items(videoBlocks) { block -> + val isSelectedBlock = block.id == currentBlock?.id + val playButtonSize = if (isSelectedBlock) { + 0.dp + } else { + 14.dp + } + val borderColor = if (isSelectedBlock) { + MaterialTheme.appColors.primary + } else { + null } - }, - onNextClick = { - handleNextClick { next, hasPrev, hasNext -> - nextButtonText = next - hasPrevBlock = hasPrev - hasNextBlock = hasNext + val borderWidth = if (isSelectedBlock) { + 3.dp + } else { + 1.dp } + CourseVideoItem( + modifier = Modifier + .width(112.dp) + .height(63.dp), + videoBlock = block, + preview = videoPreview[block.id], + progress = if (isSelectedBlock) { + 0f + } else { + videoProgress[block.id] ?: 0f + }, + onClick = { + onVideoClick(block) + }, + titleStyle = MaterialTheme.appTypography.labelSmall, + playButtonSize = playButtonSize, + borderColor = borderColor, + borderWidth = borderWidth + ) } + } + } + } + + @Composable + private fun HierarchyPathText() { + val hierarchyPath by viewModel.hierarchyPath.collectAsState() + + if (hierarchyPath.isNotEmpty()) { + Text( + overflow = TextOverflow.Ellipsis, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 24.dp), + text = hierarchyPath, + style = MaterialTheme.appTypography.bodySmall, + color = MaterialTheme.appColors.textDark, + maxLines = 2, ) } } 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 596102dd9..81382f9f3 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 @@ -9,15 +9,19 @@ import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.first import kotlinx.coroutines.launch import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withContext import org.openedx.core.BlockType import org.openedx.core.config.Config +import org.openedx.core.domain.helper.VideoPreviewHelper import org.openedx.core.domain.model.Block +import org.openedx.core.extension.safeDivBy import org.openedx.core.module.db.DownloadModel import org.openedx.core.module.db.DownloadedState import org.openedx.core.system.connection.NetworkConnection import org.openedx.core.system.notifier.CourseNotifier import org.openedx.core.system.notifier.CourseSectionChanged import org.openedx.core.system.notifier.CourseStructureUpdated +import org.openedx.core.utils.VideoPreview import org.openedx.course.domain.interactor.CourseInteractor import org.openedx.course.presentation.CourseAnalytics import org.openedx.course.presentation.CourseAnalyticsEvent @@ -29,11 +33,13 @@ import org.openedx.foundation.presentation.BaseViewModel class CourseUnitContainerViewModel( val courseId: String, val unitId: String, + val mode: CourseViewMode, private val config: Config, private val interactor: CourseInteractor, private val notifier: CourseNotifier, private val analytics: CourseAnalytics, private val networkConnection: NetworkConnection, + private val videoPreviewHelper: VideoPreviewHelper, ) : BaseViewModel() { private val blocks = ArrayList() @@ -48,12 +54,16 @@ class CourseUnitContainerViewModel( val isFirstIndexInContainer: Boolean get() { - return _descendantsBlocks.value.firstOrNull() == _descendantsBlocks.value.getOrNull(currentIndex) + return _descendantsBlocks.value.firstOrNull() == _descendantsBlocks.value.getOrNull( + currentIndex + ) } val isLastIndexInContainer: Boolean get() { - return _descendantsBlocks.value.lastOrNull() == _descendantsBlocks.value.getOrNull(currentIndex) + return _descendantsBlocks.value.lastOrNull() == _descendantsBlocks.value.getOrNull( + currentIndex + ) } private val _verticalBlockCounts = MutableLiveData() @@ -71,10 +81,23 @@ class CourseUnitContainerViewModel( private val _subSectionUnitBlocks = MutableStateFlow>(listOf()) val subSectionUnitBlocks = _subSectionUnitBlocks.asStateFlow() + private val _videoList = MutableStateFlow>(listOf()) + val videoList = _videoList.asStateFlow() + + private val _videoPreview = MutableStateFlow>(emptyMap()) + val videoPreview = _videoPreview.asStateFlow() + + private val _videoProgress = MutableStateFlow>(emptyMap()) + val videoProgress = _videoProgress.asStateFlow() + + private val _currentBlock = MutableStateFlow(null) + val currentBlock = _currentBlock.asStateFlow() + + private val _hierarchyPath = MutableStateFlow("") + val hierarchyPath = _hierarchyPath.asStateFlow() + var nextButtonText = "" var hasNextBlock = false - - private var currentMode: CourseViewMode? = null private var currentComponentId = "" private var courseName = "" @@ -84,8 +107,7 @@ class CourseUnitContainerViewModel( val hasNetworkConnection: Boolean get() = networkConnection.isOnline() - fun loadBlocks(mode: CourseViewMode, componentId: String = "") { - currentMode = mode + fun loadBlocks(componentId: String = "") { viewModelScope.launch { try { val courseStructure = when (mode) { @@ -95,7 +117,10 @@ class CourseUnitContainerViewModel( val blocks = courseStructure.blockData courseName = courseStructure.name this@CourseUnitContainerViewModel.blocks.clearAndAddAll(blocks) - + if (mode == CourseViewMode.VIDEOS) { + _videoList.value = getAllVideoBlocks() + loadVideoData() + } setupCurrentIndex(componentId) } catch (e: Exception) { e.printStackTrace() @@ -110,8 +135,7 @@ class CourseUnitContainerViewModel( notifier.notifier.collect { event -> if (event is CourseStructureUpdated) { if (event.courseId != courseId) return@collect - - currentMode?.let { loadBlocks(it, currentComponentId) } + loadBlocks(currentComponentId) val blockId = blocks[currentVerticalIndex].id _subSectionUnitBlocks.value = getSubSectionUnitBlocks(blocks, getSubSectionId(blockId)) @@ -151,6 +175,8 @@ class CourseUnitContainerViewModel( currentIndex = _descendantsBlocks.value.indexOfFirst { it.id == componentId } _indexInContainer.value = currentIndex } + // Initialize current block + _currentBlock.value = getCurrentBlock() return } } @@ -220,7 +246,10 @@ class CourseUnitContainerViewModel( } fun getCurrentBlock(): Block { - return blocks[currentIndex] + val block = _descendantsBlocks.value.getOrNull(currentIndex) ?: blocks[currentVerticalIndex] + _currentBlock.value = block + _hierarchyPath.value = buildHierarchyPath(block) + return block } fun moveToNextBlock(): Block? { @@ -237,6 +266,8 @@ class CourseUnitContainerViewModel( if (currentVerticalIndex != -1) { _indexInContainer.value = currentIndex } + _currentBlock.value = block + _hierarchyPath.value = buildHierarchyPath(block) return block } return null @@ -298,4 +329,110 @@ class CourseUnitContainerViewModel( fun setUnitsListVisibility(isVisible: Boolean) { _unitsListShowed.value = isVisible } + + fun getAllVideoBlocks(): List = blocks.filter { it.type == BlockType.VIDEO } + + fun setSelectedVideoBlock(videoBlock: Block) { + // Find the parent vertical block for this video + val verticalBlock = findParentBlock(videoBlock.id) ?: return + val verticalIndex = blocks.indexOfFirst { it.id == verticalBlock.id } + if (verticalIndex == -1) return + + // Update vertical index + currentVerticalIndex = verticalIndex + + // Find and update section index + val sectionIndex = blocks.indexOfFirst { + it.descendants.contains(blocks[currentVerticalIndex].id) + } + if (sectionIndex != currentSectionIndex) { + currentSectionIndex = sectionIndex + blocks.getOrNull(currentSectionIndex)?.id?.let { + sendCourseSectionChanged(it) + } + } + + // Update descendants blocks for the new vertical + val verticalBlockData = blocks[currentVerticalIndex] + if (verticalBlockData.descendants.isNotEmpty() || verticalBlockData.isGated()) { + _descendantsBlocks.value = + verticalBlockData.descendants.mapNotNull { descendant -> + blocks.firstOrNull { descendant == it.id } + } + _subSectionUnitBlocks.value = + getSubSectionUnitBlocks(blocks, getSubSectionId(verticalBlockData.id)) + + if (_descendantsBlocks.value.isEmpty()) { + _descendantsBlocks.value = listOf(verticalBlockData) + } + } + + // Update vertical block counts + _verticalBlockCounts.value = verticalBlockData.descendants.size + + // Find the video block index in the new descendants and set it as current + val blockIndex = _descendantsBlocks.value.indexOfFirst { it.id == videoBlock.id } + if (blockIndex != -1) { + currentIndex = blockIndex + _indexInContainer.value = currentIndex + _currentBlock.value = videoBlock + _hierarchyPath.value = buildHierarchyPath(videoBlock) + } + viewModelScope.launch { + loadVideoProgress() + } + } + + private fun findParentBlock(childId: String): Block? { + return blocks.firstOrNull { it.descendants.contains(childId) } + } + + private fun loadVideoData() { + viewModelScope.launch { + loadVideoPreview() + loadVideoProgress() + } + } + + private suspend fun loadVideoProgress() { + val videoBlocks = getAllVideoBlocks() + val videoProgress = videoBlocks.associate { block -> + val videoProgressEntity = interactor.getVideoProgress(block.id) + val progress = videoProgressEntity.videoTime?.toFloat() + ?.safeDivBy(videoProgressEntity.duration?.toFloat() ?: 0f) ?: 0f + block.id to progress + } + _videoProgress.value = videoProgress + } + + private suspend fun loadVideoPreview() { + val videoBlocks = getAllVideoBlocks() + val videoPreview = withContext(Dispatchers.IO) { + videoPreviewHelper.getVideoPreviews(videoBlocks) + } + _videoPreview.value = videoPreview + } + + private fun buildHierarchyPath(block: Block): String { + val pathComponents = mutableListOf() + + val verticalBlock = findParentBlock(block.id) + verticalBlock?.let { vertical -> + // Vertical name + pathComponents.add(0, vertical.displayName) + // Find the parent Sequential block (Subsection) + val sequentialBlock = findParentBlock(vertical.id) + sequentialBlock?.let { sequential -> + pathComponents.add(0, sequential.displayName) + + // Find the parent Chapter block (Section) + val chapterBlock = findParentBlock(sequential.id) + chapterBlock?.let { chapter -> + pathComponents.add(0, chapter.displayName) + } + } + } + + return pathComponents.joinToString(" > ") + } } diff --git a/course/src/main/java/org/openedx/course/presentation/unit/video/YoutubeVideoFullScreenFragment.kt b/course/src/main/java/org/openedx/course/presentation/unit/video/YoutubeVideoFullScreenFragment.kt index 0f4a75697..0dda9e50b 100644 --- a/course/src/main/java/org/openedx/course/presentation/unit/video/YoutubeVideoFullScreenFragment.kt +++ b/course/src/main/java/org/openedx/course/presentation/unit/video/YoutubeVideoFullScreenFragment.kt @@ -7,12 +7,12 @@ import androidx.core.os.bundleOf import androidx.core.view.WindowInsetsCompat import androidx.core.view.isVisible import androidx.fragment.app.Fragment +import com.pierfrancescosoffritti.androidyoutubeplayer.core.customui.DefaultPlayerUiController import com.pierfrancescosoffritti.androidyoutubeplayer.core.player.PlayerConstants import com.pierfrancescosoffritti.androidyoutubeplayer.core.player.YouTubePlayer import com.pierfrancescosoffritti.androidyoutubeplayer.core.player.listeners.AbstractYouTubePlayerListener import com.pierfrancescosoffritti.androidyoutubeplayer.core.player.options.IFramePlayerOptions import com.pierfrancescosoffritti.androidyoutubeplayer.core.player.utils.YouTubePlayerTracker -import com.pierfrancescosoffritti.androidyoutubeplayer.core.ui.DefaultPlayerUiController import org.koin.android.ext.android.inject import org.koin.androidx.viewmodel.ext.android.viewModel import org.koin.core.parameter.parametersOf @@ -66,7 +66,7 @@ class YoutubeVideoFullScreenFragment : Fragment(R.layout.fragment_youtube_video_ binding.root.requestApplyInsetsWhenAttached() lifecycle.addObserver(binding.youtubePlayerView) - val options = IFramePlayerOptions.Builder() + val options = IFramePlayerOptions.Builder(requireContext()) .controls(0) .rel(0) .build() @@ -110,7 +110,7 @@ class YoutubeVideoFullScreenFragment : Fragment(R.layout.fragment_youtube_video_ binding.youtubePlayerView.isVisible = true val defPlayerUiController = DefaultPlayerUiController(binding.youtubePlayerView, youTubePlayer) - defPlayerUiController.setFullScreenButtonClickListener { + defPlayerUiController.setFullscreenButtonClickListener { parentFragmentManager.popBackStack() } diff --git a/course/src/main/java/org/openedx/course/presentation/unit/video/YoutubeVideoUnitFragment.kt b/course/src/main/java/org/openedx/course/presentation/unit/video/YoutubeVideoUnitFragment.kt index 1afe71e91..352858cbe 100644 --- a/course/src/main/java/org/openedx/course/presentation/unit/video/YoutubeVideoUnitFragment.kt +++ b/course/src/main/java/org/openedx/course/presentation/unit/video/YoutubeVideoUnitFragment.kt @@ -11,12 +11,12 @@ import androidx.compose.runtime.livedata.observeAsState import androidx.core.os.bundleOf import androidx.core.view.isVisible import androidx.fragment.app.Fragment +import com.pierfrancescosoffritti.androidyoutubeplayer.core.customui.DefaultPlayerUiController import com.pierfrancescosoffritti.androidyoutubeplayer.core.player.PlayerConstants import com.pierfrancescosoffritti.androidyoutubeplayer.core.player.YouTubePlayer import com.pierfrancescosoffritti.androidyoutubeplayer.core.player.listeners.AbstractYouTubePlayerListener import com.pierfrancescosoffritti.androidyoutubeplayer.core.player.options.IFramePlayerOptions import com.pierfrancescosoffritti.androidyoutubeplayer.core.player.utils.YouTubePlayerTracker -import com.pierfrancescosoffritti.androidyoutubeplayer.core.ui.DefaultPlayerUiController import org.koin.android.ext.android.inject import org.koin.androidx.viewmodel.ext.android.viewModel import org.koin.core.parameter.parametersOf @@ -142,7 +142,7 @@ class YoutubeVideoUnitFragment : Fragment(R.layout.fragment_youtube_video_unit) lifecycle.addObserver(binding.youtubePlayerView) - val options = IFramePlayerOptions.Builder() + val options = IFramePlayerOptions.Builder(requireContext()) .controls(0) .rel(0) .build() @@ -189,7 +189,7 @@ class YoutubeVideoUnitFragment : Fragment(R.layout.fragment_youtube_video_unit) binding.youtubePlayerView, youTubePlayer ) - defPlayerUiController.setFullScreenButtonClickListener { + defPlayerUiController.setFullscreenButtonClickListener { router.navigateToFullScreenYoutubeVideo( requireActivity().supportFragmentManager, viewModel.videoUrl, 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 5dffd7688..456669cc0 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,7 +1,5 @@ package org.openedx.course.presentation.videos -import android.annotation.SuppressLint -import android.content.Context import androidx.fragment.app.FragmentManager import androidx.lifecycle.viewModelScope import kotlinx.coroutines.Dispatchers @@ -15,6 +13,7 @@ import kotlinx.coroutines.launch import org.openedx.core.BlockType import org.openedx.core.config.Config import org.openedx.core.data.storage.CorePreferences +import org.openedx.core.domain.helper.VideoPreviewHelper import org.openedx.core.domain.model.Block import org.openedx.core.extension.safeDivBy import org.openedx.core.module.DownloadWorkerController @@ -37,10 +36,8 @@ import org.openedx.foundation.presentation.UIMessage import org.openedx.foundation.system.ResourceManager import org.openedx.foundation.utils.FileUtil -@SuppressLint("StaticFieldLeak") class CourseVideoViewModel( val courseId: String, - private val context: Context, private val config: Config, private val interactor: CourseInteractor, private val resourceManager: ResourceManager, @@ -51,6 +48,7 @@ class CourseVideoViewModel( private val fileUtil: FileUtil, val courseRouter: CourseRouter, private val analytics: CourseAnalytics, + private val videoPreviewHelper: VideoPreviewHelper, coreAnalytics: CoreAnalytics, downloadDao: DownloadDao, workerController: DownloadWorkerController, @@ -188,10 +186,11 @@ class CourseVideoViewModel( viewModelScope.launch(Dispatchers.IO) { val downloadingModels = getDownloadModelList() courseVideos.values.flatten().forEach { block -> - val previewMap = block.id to block.getVideoPreview( - context, - networkConnection.isOnline(), - downloadingModels.find { block.id == it.id }?.path + val offlineUrl = downloadingModels.find { block.id == it.id }?.path + val previewMap = videoPreviewHelper.getVideoPreviewWithId( + blockId = block.id, + block = block, + offlineUrl = offlineUrl ) val currentUiState = (_uiState.value as? CourseVideoUIState.CourseData) ?: return@forEach diff --git a/course/src/main/res/layout-w600dp-h480dp/fragment_course_unit_container.xml b/course/src/main/res/layout-w600dp-h480dp/fragment_course_unit_container.xml index f06c77405..697e0840a 100644 --- a/course/src/main/res/layout-w600dp-h480dp/fragment_course_unit_container.xml +++ b/course/src/main/res/layout-w600dp-h480dp/fragment_course_unit_container.xml @@ -39,6 +39,14 @@ app:layout_constraintStart_toStartOf="parent" app:layout_constraintTop_toBottomOf="@+id/frameBack" /> + + + app:layout_constraintTop_toBottomOf="@+id/videoList" /> + + + app:layout_constraintTop_toBottomOf="@+id/videoList" /> () private val config = mockk() private val interactor = mockk() private val resourceManager = mockk() @@ -71,6 +70,7 @@ class CourseHomeViewModelTest { private val downloadDao = mockk() private val workerController = mockk() private val downloadHelper = mockk() + private val videoPreviewHelper = mockk() private val noInternet = "Slow or no internet connection" private val somethingWrong = "Something went wrong" @@ -130,6 +130,8 @@ class CourseHomeViewModelTest { } returns Unit coEvery { workerController.saveModels(any()) } returns Unit + + every { videoPreviewHelper.getVideoPreview(any(), any()) } returns null } @After @@ -162,7 +164,6 @@ class CourseHomeViewModelTest { val viewModel = CourseHomeViewModel( courseId = courseId, courseTitle = courseTitle, - context = context, config = config, interactor = interactor, resourceManager = resourceManager, @@ -173,6 +174,7 @@ class CourseHomeViewModelTest { downloadDialogManager = downloadDialogManager, fileUtil = fileUtil, courseRouter = courseRouter, + videoPreviewHelper = videoPreviewHelper, coreAnalytics = coreAnalytics, downloadDao = downloadDao, workerController = workerController, @@ -218,7 +220,6 @@ class CourseHomeViewModelTest { val viewModel = CourseHomeViewModel( courseId = courseId, courseTitle = courseTitle, - context = context, config = config, interactor = interactor, resourceManager = resourceManager, @@ -229,6 +230,7 @@ class CourseHomeViewModelTest { downloadDialogManager = downloadDialogManager, fileUtil = fileUtil, courseRouter = courseRouter, + videoPreviewHelper = videoPreviewHelper, coreAnalytics = coreAnalytics, downloadDao = downloadDao, workerController = workerController, @@ -266,7 +268,6 @@ class CourseHomeViewModelTest { val viewModel = CourseHomeViewModel( courseId = courseId, courseTitle = courseTitle, - context = context, config = config, interactor = interactor, resourceManager = resourceManager, @@ -277,6 +278,7 @@ class CourseHomeViewModelTest { downloadDialogManager = downloadDialogManager, fileUtil = fileUtil, courseRouter = courseRouter, + videoPreviewHelper = videoPreviewHelper, coreAnalytics = coreAnalytics, downloadDao = downloadDao, workerController = workerController, @@ -315,7 +317,6 @@ class CourseHomeViewModelTest { val viewModel = CourseHomeViewModel( courseId = courseId, courseTitle = courseTitle, - context = context, config = config, interactor = interactor, resourceManager = resourceManager, @@ -326,6 +327,7 @@ class CourseHomeViewModelTest { downloadDialogManager = downloadDialogManager, fileUtil = fileUtil, courseRouter = courseRouter, + videoPreviewHelper = videoPreviewHelper, coreAnalytics = coreAnalytics, downloadDao = downloadDao, workerController = workerController, @@ -364,7 +366,6 @@ class CourseHomeViewModelTest { val viewModel = CourseHomeViewModel( courseId = courseId, courseTitle = courseTitle, - context = context, config = config, interactor = interactor, resourceManager = resourceManager, @@ -375,6 +376,7 @@ class CourseHomeViewModelTest { downloadDialogManager = downloadDialogManager, fileUtil = fileUtil, courseRouter = courseRouter, + videoPreviewHelper = videoPreviewHelper, coreAnalytics = coreAnalytics, downloadDao = downloadDao, workerController = workerController, @@ -421,7 +423,6 @@ class CourseHomeViewModelTest { val viewModel = CourseHomeViewModel( courseId = courseId, courseTitle = courseTitle, - context = context, config = config, interactor = interactor, resourceManager = resourceManager, @@ -432,6 +433,7 @@ class CourseHomeViewModelTest { downloadDialogManager = downloadDialogManager, fileUtil = fileUtil, courseRouter = courseRouter, + videoPreviewHelper = videoPreviewHelper, coreAnalytics = coreAnalytics, downloadDao = downloadDao, workerController = workerController, @@ -477,7 +479,6 @@ class CourseHomeViewModelTest { val viewModel = CourseHomeViewModel( courseId = courseId, courseTitle = courseTitle, - context = context, config = config, interactor = interactor, resourceManager = resourceManager, @@ -488,6 +489,7 @@ class CourseHomeViewModelTest { downloadDialogManager = downloadDialogManager, fileUtil = fileUtil, courseRouter = courseRouter, + videoPreviewHelper = videoPreviewHelper, coreAnalytics = coreAnalytics, downloadDao = downloadDao, workerController = workerController, @@ -535,7 +537,6 @@ class CourseHomeViewModelTest { val viewModel = CourseHomeViewModel( courseId = courseId, courseTitle = courseTitle, - context = context, config = config, interactor = interactor, resourceManager = resourceManager, @@ -546,6 +547,7 @@ class CourseHomeViewModelTest { downloadDialogManager = downloadDialogManager, fileUtil = fileUtil, courseRouter = courseRouter, + videoPreviewHelper = videoPreviewHelper, coreAnalytics = coreAnalytics, downloadDao = downloadDao, workerController = workerController, @@ -593,7 +595,6 @@ class CourseHomeViewModelTest { val viewModel = CourseHomeViewModel( courseId = courseId, courseTitle = courseTitle, - context = context, config = config, interactor = interactor, resourceManager = resourceManager, @@ -604,6 +605,7 @@ class CourseHomeViewModelTest { downloadDialogManager = downloadDialogManager, fileUtil = fileUtil, courseRouter = courseRouter, + videoPreviewHelper = videoPreviewHelper, coreAnalytics = coreAnalytics, downloadDao = downloadDao, workerController = workerController, @@ -649,7 +651,6 @@ class CourseHomeViewModelTest { val viewModel = CourseHomeViewModel( courseId = courseId, courseTitle = courseTitle, - context = context, config = config, interactor = interactor, resourceManager = resourceManager, @@ -660,6 +661,7 @@ class CourseHomeViewModelTest { downloadDialogManager = downloadDialogManager, fileUtil = fileUtil, courseRouter = courseRouter, + videoPreviewHelper = videoPreviewHelper, coreAnalytics = coreAnalytics, downloadDao = downloadDao, workerController = workerController, @@ -699,7 +701,6 @@ class CourseHomeViewModelTest { val viewModel = CourseHomeViewModel( courseId = courseId, courseTitle = courseTitle, - context = context, config = config, interactor = interactor, resourceManager = resourceManager, @@ -710,6 +711,7 @@ class CourseHomeViewModelTest { downloadDialogManager = downloadDialogManager, fileUtil = fileUtil, courseRouter = courseRouter, + videoPreviewHelper = videoPreviewHelper, coreAnalytics = coreAnalytics, downloadDao = downloadDao, workerController = workerController, @@ -747,7 +749,6 @@ class CourseHomeViewModelTest { val viewModel = CourseHomeViewModel( courseId = courseId, courseTitle = courseTitle, - context = context, config = config, interactor = interactor, resourceManager = resourceManager, @@ -758,6 +759,7 @@ class CourseHomeViewModelTest { downloadDialogManager = downloadDialogManager, fileUtil = fileUtil, courseRouter = courseRouter, + videoPreviewHelper = videoPreviewHelper, coreAnalytics = coreAnalytics, downloadDao = downloadDao, workerController = workerController, @@ -793,7 +795,6 @@ class CourseHomeViewModelTest { val viewModel = CourseHomeViewModel( courseId = courseId, courseTitle = courseTitle, - context = context, config = config, interactor = interactor, resourceManager = resourceManager, @@ -804,6 +805,7 @@ class CourseHomeViewModelTest { downloadDialogManager = downloadDialogManager, fileUtil = fileUtil, courseRouter = courseRouter, + videoPreviewHelper = videoPreviewHelper, coreAnalytics = coreAnalytics, downloadDao = downloadDao, workerController = workerController, @@ -841,7 +843,6 @@ class CourseHomeViewModelTest { val viewModel = CourseHomeViewModel( courseId = courseId, courseTitle = courseTitle, - context = context, config = config, interactor = interactor, resourceManager = resourceManager, @@ -852,6 +853,7 @@ class CourseHomeViewModelTest { downloadDialogManager = downloadDialogManager, fileUtil = fileUtil, courseRouter = courseRouter, + videoPreviewHelper = videoPreviewHelper, coreAnalytics = coreAnalytics, downloadDao = downloadDao, workerController = workerController, 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 909fe0e8f..fb8ac2920 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.helper.VideoPreviewHelper import org.openedx.core.domain.model.AssignmentProgress import org.openedx.core.domain.model.Block import org.openedx.core.domain.model.BlockCounts @@ -45,6 +46,7 @@ class CourseUnitContainerViewModelTest { private val notifier = mockk() private val analytics = mockk() private val networkConnection = mockk() + private val videoPreviewHelper = mockk() private val assignmentProgress = AssignmentProgress( assignmentType = "Homework", @@ -161,6 +163,7 @@ class CourseUnitContainerViewModelTest { @Before fun setUp() { Dispatchers.setMain(dispatcher) + every { videoPreviewHelper.getVideoPreviews(any(), any()) } returns emptyMap() } @After @@ -171,13 +174,22 @@ class CourseUnitContainerViewModelTest { @Test fun `getBlocks no internet connection exception`() = runTest { every { notifier.notifier } returns MutableSharedFlow() - val viewModel = - CourseUnitContainerViewModel("", "", config, interactor, notifier, analytics, networkConnection) + val viewModel = CourseUnitContainerViewModel( + "", + "", + CourseViewMode.FULL, + config, + interactor, + notifier, + analytics, + networkConnection, + videoPreviewHelper + ) coEvery { interactor.getCourseStructure(any()) } throws UnknownHostException() coEvery { interactor.getCourseStructureForVideos(any()) } throws UnknownHostException() - viewModel.loadBlocks(CourseViewMode.FULL) + viewModel.loadBlocks() advanceUntilIdle() coVerify(exactly = 1) { interactor.getCourseStructure(any()) } @@ -186,13 +198,22 @@ class CourseUnitContainerViewModelTest { @Test fun `getBlocks unknown exception`() = runTest { every { notifier.notifier } returns MutableSharedFlow() - val viewModel = - CourseUnitContainerViewModel("", "", config, interactor, notifier, analytics, networkConnection) + val viewModel = CourseUnitContainerViewModel( + "", + "", + CourseViewMode.FULL, + config, + interactor, + notifier, + analytics, + networkConnection, + videoPreviewHelper + ) coEvery { interactor.getCourseStructure(any()) } throws UnknownHostException() coEvery { interactor.getCourseStructureForVideos(any()) } throws UnknownHostException() - viewModel.loadBlocks(CourseViewMode.FULL) + viewModel.loadBlocks() advanceUntilIdle() coVerify(exactly = 1) { interactor.getCourseStructure(any()) } @@ -201,13 +222,22 @@ class CourseUnitContainerViewModelTest { @Test fun `getBlocks unknown success`() = runTest { every { notifier.notifier } returns MutableSharedFlow() - val viewModel = - CourseUnitContainerViewModel("", "", config, interactor, notifier, analytics, networkConnection) + val viewModel = CourseUnitContainerViewModel( + "", + "", + CourseViewMode.VIDEOS, + config, + interactor, + notifier, + analytics, + networkConnection, + videoPreviewHelper + ) coEvery { interactor.getCourseStructure(any()) } returns courseStructure coEvery { interactor.getCourseStructureForVideos(any()) } returns courseStructure - viewModel.loadBlocks(CourseViewMode.VIDEOS) + viewModel.loadBlocks() advanceUntilIdle() @@ -218,12 +248,21 @@ class CourseUnitContainerViewModelTest { @Test fun setupCurrentIndex() = runTest { every { notifier.notifier } returns MutableSharedFlow() - val viewModel = - CourseUnitContainerViewModel("", "", config, interactor, notifier, analytics, networkConnection) + val viewModel = CourseUnitContainerViewModel( + "", + "", + CourseViewMode.VIDEOS, + config, + interactor, + notifier, + analytics, + networkConnection, + videoPreviewHelper + ) coEvery { interactor.getCourseStructure(any()) } returns courseStructure coEvery { interactor.getCourseStructureForVideos(any()) } returns courseStructure - viewModel.loadBlocks(CourseViewMode.VIDEOS, "id") + viewModel.loadBlocks("id") advanceUntilIdle() coVerify(exactly = 0) { interactor.getCourseStructure(any()) } @@ -233,12 +272,21 @@ class CourseUnitContainerViewModelTest { @Test fun `getCurrentBlock test`() = runTest { every { notifier.notifier } returns MutableSharedFlow() - val viewModel = - CourseUnitContainerViewModel("", "", config, interactor, notifier, analytics, networkConnection) + val viewModel = CourseUnitContainerViewModel( + "", + "", + CourseViewMode.VIDEOS, + config, + interactor, + notifier, + analytics, + networkConnection, + videoPreviewHelper + ) coEvery { interactor.getCourseStructure(any()) } returns courseStructure coEvery { interactor.getCourseStructureForVideos(any()) } returns courseStructure - viewModel.loadBlocks(CourseViewMode.VIDEOS, "id") + viewModel.loadBlocks("id") advanceUntilIdle() @@ -250,12 +298,21 @@ class CourseUnitContainerViewModelTest { @Test fun `moveToPrevBlock null`() = runTest { every { notifier.notifier } returns MutableSharedFlow() - val viewModel = - CourseUnitContainerViewModel("", "", config, interactor, notifier, analytics, networkConnection) + val viewModel = CourseUnitContainerViewModel( + "", + "", + CourseViewMode.VIDEOS, + config, + interactor, + notifier, + analytics, + networkConnection, + videoPreviewHelper + ) coEvery { interactor.getCourseStructure(any()) } returns courseStructure coEvery { interactor.getCourseStructureForVideos(any()) } returns courseStructure - viewModel.loadBlocks(CourseViewMode.VIDEOS, "id") + viewModel.loadBlocks("id3") advanceUntilIdle() @@ -267,12 +324,21 @@ class CourseUnitContainerViewModelTest { @Test fun `moveToPrevBlock not null`() = runTest { every { notifier.notifier } returns MutableSharedFlow() - val viewModel = - CourseUnitContainerViewModel("", "id", config, interactor, notifier, analytics, networkConnection) + val viewModel = CourseUnitContainerViewModel( + "", + "id", + CourseViewMode.VIDEOS, + config, + interactor, + notifier, + analytics, + networkConnection, + videoPreviewHelper + ) coEvery { interactor.getCourseStructure(any()) } returns courseStructure coEvery { interactor.getCourseStructureForVideos(any()) } returns courseStructure - viewModel.loadBlocks(CourseViewMode.VIDEOS, "id1") + viewModel.loadBlocks("id1") advanceUntilIdle() @@ -284,12 +350,21 @@ class CourseUnitContainerViewModelTest { @Test fun `moveToNextBlock null`() = runTest { every { notifier.notifier } returns MutableSharedFlow() - val viewModel = - CourseUnitContainerViewModel("", "", config, interactor, notifier, analytics, networkConnection) + val viewModel = CourseUnitContainerViewModel( + "", + "", + CourseViewMode.VIDEOS, + config, + interactor, + notifier, + analytics, + networkConnection, + videoPreviewHelper + ) coEvery { interactor.getCourseStructure(any()) } returns courseStructure coEvery { interactor.getCourseStructureForVideos(any()) } returns courseStructure - viewModel.loadBlocks(CourseViewMode.VIDEOS, "id3") + viewModel.loadBlocks("id3") advanceUntilIdle() @@ -301,12 +376,21 @@ class CourseUnitContainerViewModelTest { @Test fun `moveToNextBlock not null`() = runTest { every { notifier.notifier } returns MutableSharedFlow() - val viewModel = - CourseUnitContainerViewModel("", "id", config, interactor, notifier, analytics, networkConnection) + val viewModel = CourseUnitContainerViewModel( + "", + "id", + CourseViewMode.VIDEOS, + config, + interactor, + notifier, + analytics, + networkConnection, + videoPreviewHelper + ) coEvery { interactor.getCourseStructure("") } returns courseStructure coEvery { interactor.getCourseStructureForVideos("") } returns courseStructure - viewModel.loadBlocks(CourseViewMode.VIDEOS, "id") + viewModel.loadBlocks("id") advanceUntilIdle() @@ -318,12 +402,21 @@ class CourseUnitContainerViewModelTest { @Test fun `currentIndex isLastIndex`() = runTest { every { notifier.notifier } returns MutableSharedFlow() - val viewModel = - CourseUnitContainerViewModel("", "", config, interactor, notifier, analytics, networkConnection) + val viewModel = CourseUnitContainerViewModel( + "", + "", + CourseViewMode.VIDEOS, + config, + interactor, + notifier, + analytics, + networkConnection, + videoPreviewHelper + ) coEvery { interactor.getCourseStructure(any()) } returns courseStructure coEvery { interactor.getCourseStructureForVideos(any()) } returns courseStructure - viewModel.loadBlocks(CourseViewMode.VIDEOS, "id3") + viewModel.loadBlocks("id3") advanceUntilIdle() 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 38ff2e49f..e8a16c151 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 @@ -1,6 +1,5 @@ package org.openedx.course.presentation.videos -import android.content.Context import androidx.arch.core.executor.testing.InstantTaskExecutorRule import androidx.lifecycle.Lifecycle import androidx.lifecycle.LifecycleOwner @@ -32,6 +31,7 @@ import org.openedx.core.BlockType import org.openedx.core.config.Config import org.openedx.core.data.model.room.VideoProgressEntity import org.openedx.core.data.storage.CorePreferences +import org.openedx.core.domain.helper.VideoPreviewHelper import org.openedx.core.domain.model.AssignmentProgress import org.openedx.core.domain.model.Block import org.openedx.core.domain.model.BlockCounts @@ -66,7 +66,6 @@ class CourseVideoViewModelTest { private val dispatcher = UnconfinedTestDispatcher() - private val context = mockk() private val config = mockk() private val resourceManager = mockk() private val interactor = mockk() @@ -81,6 +80,7 @@ class CourseVideoViewModelTest { private val downloadHelper = mockk() private val downloadDialogManager = mockk() private val fileUtil = mockk() + private val videoPreviewHelper = mockk() private val cantDownload = "You can download content only from Wi-fi" @@ -211,6 +211,11 @@ class CourseVideoViewModelTest { any(), ) } returns Unit + + every { videoPreviewHelper.getVideoPreviewWithId(any(), any(), any()) } returns Pair( + "test", + null + ) } @After @@ -228,7 +233,6 @@ class CourseVideoViewModelTest { every { preferencesManager.videoSettings } returns VideoSettings.default val viewModel = CourseVideoViewModel( "", - context, config, interactor, resourceManager, @@ -239,6 +243,7 @@ class CourseVideoViewModelTest { fileUtil, courseRouter, courseAnalytics, + videoPreviewHelper, coreAnalytics, downloadDao, workerController, @@ -266,7 +271,6 @@ class CourseVideoViewModelTest { every { preferencesManager.videoSettings } returns VideoSettings.default val viewModel = CourseVideoViewModel( "", - context, config, interactor, resourceManager, @@ -277,6 +281,7 @@ class CourseVideoViewModelTest { fileUtil, courseRouter, courseAnalytics, + videoPreviewHelper, coreAnalytics, downloadDao, workerController, @@ -310,7 +315,6 @@ class CourseVideoViewModelTest { coEvery { interactor.getVideoProgress(any()) } returns VideoProgressEntity("", "", 0L, 0L) val viewModel = CourseVideoViewModel( "", - context, config, interactor, resourceManager, @@ -321,6 +325,7 @@ class CourseVideoViewModelTest { fileUtil, courseRouter, courseAnalytics, + videoPreviewHelper, coreAnalytics, downloadDao, workerController, @@ -356,7 +361,6 @@ class CourseVideoViewModelTest { every { downloadDao.getAllDataFlow() } returns flow { emit(emptyList()) } val viewModel = CourseVideoViewModel( "", - context, config, interactor, resourceManager, @@ -367,6 +371,7 @@ class CourseVideoViewModelTest { fileUtil, courseRouter, courseAnalytics, + videoPreviewHelper, coreAnalytics, downloadDao, workerController, @@ -398,7 +403,6 @@ class CourseVideoViewModelTest { every { downloadDao.getAllDataFlow() } returns flow { emit(emptyList()) } val viewModel = CourseVideoViewModel( "", - context, config, interactor, resourceManager, @@ -409,6 +413,7 @@ class CourseVideoViewModelTest { fileUtil, courseRouter, courseAnalytics, + videoPreviewHelper, coreAnalytics, downloadDao, workerController, @@ -444,7 +449,6 @@ class CourseVideoViewModelTest { coEvery { interactor.getCourseStructureForVideos(any()) } returns courseStructure val viewModel = CourseVideoViewModel( "", - context, config, interactor, resourceManager, @@ -455,6 +459,7 @@ class CourseVideoViewModelTest { fileUtil, courseRouter, courseAnalytics, + videoPreviewHelper, coreAnalytics, downloadDao, workerController, diff --git a/default_config/dev/config.yaml b/default_config/dev/config.yaml index a7f265a45..952e041de 100644 --- a/default_config/dev/config.yaml +++ b/default_config/dev/config.yaml @@ -67,6 +67,8 @@ BRANCH: EXPERIMENTAL_FEATURES: APP_LEVEL_DOWNLOADS: ENABLED: false + APP_LEVEL_DATES: + ENABLED: false #Platform names PLATFORM_NAME: "OpenEdX"