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
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 3afb802dc..0bb3c0593 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
@@ -261,7 +261,7 @@ private fun CourseOutlineUI(
.fillMaxWidth()
.padding(vertical = 12.dp)
.then(listPadding),
- icon = painterResource(R.drawable.ic_course_certificate),
+ icon = painterResource(R.drawable.course_ic_certificate),
message = stringResource(
R.string.course_you_earned_certificate,
uiState.courseStructure.name
@@ -499,10 +499,10 @@ private fun CourseProgress(
fun getUnitBlockIcon(block: Block): Int {
return when (block.type) {
- BlockType.VIDEO -> R.drawable.ic_course_video
- BlockType.PROBLEM -> R.drawable.ic_course_pen
- BlockType.DISCUSSION -> R.drawable.ic_course_discussion
- else -> R.drawable.ic_course_block
+ BlockType.VIDEO -> R.drawable.course_ic_video
+ BlockType.PROBLEM -> R.drawable.course_ic_pen
+ BlockType.DISCUSSION -> R.drawable.course_ic_discussion
+ else -> R.drawable.course_ic_block
}
}
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 75a100ab8..d1f784227 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
@@ -290,7 +290,7 @@ private fun CourseSubsectionItem(
painterResource(R.drawable.course_ic_task_alt)
} else {
painterResource(
- CoreR.drawable.ic_core_chapter_icon
+ CoreR.drawable.core_ic_chapter_icon
)
}
val completedIconColor =
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 e713c7d14..755ecbafa 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
@@ -137,7 +137,7 @@ fun CourseSectionCard(
val completedIconPainter = if (block.isCompleted()) {
painterResource(R.drawable.course_ic_task_alt)
} else {
- painterResource(coreR.drawable.ic_core_chapter_icon)
+ painterResource(coreR.drawable.core_ic_chapter_icon)
}
val completedIconColor = if (block.isCompleted()) {
MaterialTheme.appColors.primary
@@ -781,7 +781,7 @@ fun CourseSubSectionItem(
val icon = if (block.isCompleted()) {
painterResource(R.drawable.course_ic_task_alt)
} else {
- painterResource(coreR.drawable.ic_core_chapter_icon)
+ painterResource(coreR.drawable.core_ic_chapter_icon)
}
val iconColor = if (block.isCompleted()) {
MaterialTheme.appColors.successGreen
@@ -912,7 +912,7 @@ fun SubSectionUnitsTitle(
if (hasMultipleUnits) {
Icon(
modifier = Modifier.rotate(if (unitsListShowed) 180f else 0f),
- painter = painterResource(id = R.drawable.ic_course_arrow_down),
+ painter = painterResource(id = R.drawable.course_ic_arrow_down),
contentDescription = null,
tint = MaterialTheme.appColors.textPrimary
)
@@ -955,7 +955,7 @@ fun SubSectionUnitsList(
modifier = Modifier
.size(16.dp)
.alpha(if (unit.isCompleted()) 1f else 0f),
- painter = painterResource(id = coreR.drawable.ic_core_check),
+ painter = painterResource(id = coreR.drawable.core_ic_check),
contentDescription = "done"
)
Text(
@@ -989,7 +989,7 @@ fun SubSectionUnitsList(
Image(
modifier = Modifier
.size(16.dp),
- painter = painterResource(id = R.drawable.ic_course_gated),
+ painter = painterResource(id = R.drawable.course_ic_gated),
contentDescription = "gated"
)
Text(
@@ -1348,7 +1348,7 @@ private fun CourseMessagePreview() {
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 24.dp, vertical = 12.dp),
- icon = painterResource(R.drawable.ic_course_certificate),
+ icon = painterResource(R.drawable.course_ic_certificate),
message = stringResource(
R.string.course_you_earned_certificate,
"Demo Course"
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 5f498c162..b020a11cc 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
@@ -331,7 +331,7 @@ private fun CourseVideosUI(
}
) {
Text(
- text = stringResource(id = org.openedx.core.R.string.core_confirm)
+ text = stringResource(id = coreR.string.core_confirm)
)
}
},
@@ -341,7 +341,7 @@ private fun CourseVideosUI(
isDownloadConfirmationShowed = false
}
) {
- Text(text = stringResource(id = org.openedx.core.R.string.core_dismiss))
+ Text(text = stringResource(id = coreR.string.core_dismiss))
}
}
)
@@ -362,7 +362,7 @@ private fun CourseVideosUI(
AlertDialog(
title = {
Text(
- text = stringResource(id = org.openedx.core.R.string.core_warning)
+ text = stringResource(id = coreR.string.core_warning)
)
},
text = {
@@ -381,7 +381,7 @@ private fun CourseVideosUI(
}
) {
Text(
- text = stringResource(id = org.openedx.core.R.string.core_delete)
+ text = stringResource(id = coreR.string.core_delete)
)
}
},
@@ -391,7 +391,7 @@ private fun CourseVideosUI(
isDeleteDownloadsConfirmationShowed = false
}
) {
- Text(text = stringResource(id = org.openedx.core.R.string.core_cancel))
+ Text(text = stringResource(id = coreR.string.core_cancel))
}
}
)
@@ -401,7 +401,7 @@ private fun CourseVideosUI(
AlertDialog(
title = {
Text(
- text = stringResource(id = org.openedx.core.R.string.core_warning)
+ text = stringResource(id = coreR.string.core_warning)
)
},
text = {
@@ -425,7 +425,7 @@ private fun CourseVideosUI(
}
) {
Text(
- text = stringResource(id = org.openedx.core.R.string.core_delete)
+ text = stringResource(id = coreR.string.core_delete)
)
}
},
@@ -435,7 +435,7 @@ private fun CourseVideosUI(
deleteDownloadBlock = null
}
) {
- Text(text = stringResource(id = org.openedx.core.R.string.core_cancel))
+ Text(text = stringResource(id = coreR.string.core_cancel))
}
}
)
@@ -460,20 +460,20 @@ private fun AllVideosDownloadItem(
downloadModelsSize.remainingCount == 0
val downloadVideoTitleRes = when {
- isDownloadingAllVideos -> org.openedx.core.R.string.core_video_downloading_to_device
- isDownloadedAllVideos -> org.openedx.core.R.string.core_video_downloaded_to_device
- else -> org.openedx.core.R.string.core_video_download_to_device
+ 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 = org.openedx.core.R.string.core_video_downloaded_subtitle,
+ id = coreR.string.core_video_downloaded_subtitle,
downloadModelsSize.allCount,
downloadModelsSize.allSize.toFileSize()
)
} else {
stringResource(
- id = org.openedx.core.R.string.core_video_remaining_to_download,
+ id = coreR.string.core_video_remaining_to_download,
downloadModelsSize.remainingCount,
downloadModelsSize.remainingSize.toFileSize()
)
@@ -589,7 +589,7 @@ private fun AllVideosDownloadItem(
.padding(8.dp)
) {
Text(
- text = stringResource(id = org.openedx.core.R.string.core_video_download_quality),
+ text = stringResource(id = coreR.string.core_video_download_quality),
color = MaterialTheme.appColors.textPrimary,
style = MaterialTheme.appTypography.titleMedium
)
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 9c8340897..745f3c67a 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
@@ -93,7 +93,7 @@ class VideoFullScreenFragment : Fragment(R.layout.fragment_video_full_screen) {
initPlayer()
}
- @androidx.annotation.OptIn(androidx.media3.common.util.UnstableApi::class)
+ @OptIn(UnstableApi::class)
private fun initPlayer() {
if (exoPlayer == null) {
exoPlayer = buildExoPlayer()
@@ -175,7 +175,7 @@ class VideoFullScreenFragment : Fragment(R.layout.fragment_video_full_screen) {
})
}
- @androidx.annotation.OptIn(androidx.media3.common.util.UnstableApi::class)
+ @OptIn(UnstableApi::class)
private fun setPlayerMedia(mediaItem: MediaItem) {
if (viewModel.videoUrl.endsWith(".m3u8")) {
val factory = DefaultDataSource.Factory(requireContext())
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 708b9610a..e599b0f95 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
@@ -277,7 +277,7 @@ class VideoUnitFragment : Fragment(R.layout.fragment_video_unit) {
}
}
- @androidx.annotation.OptIn(androidx.media3.common.util.UnstableApi::class)
+ @androidx.annotation.OptIn(UnstableApi::class)
private fun setPlayerMedia(mediaItem: MediaItem) {
if (viewModel.videoUrl.endsWith(".m3u8")) {
val factory = DefaultDataSource.Factory(requireContext())
diff --git a/course/src/main/res/anim/course_slide_in_down.xml b/course/src/main/res/anim/course_slide_in_down.xml
deleted file mode 100644
index 6201b1915..000000000
--- a/course/src/main/res/anim/course_slide_in_down.xml
+++ /dev/null
@@ -1,7 +0,0 @@
-
-
-
-
\ No newline at end of file
diff --git a/course/src/main/res/anim/course_slide_in_up.xml b/course/src/main/res/anim/course_slide_in_up.xml
deleted file mode 100644
index 8aa389749..000000000
--- a/course/src/main/res/anim/course_slide_in_up.xml
+++ /dev/null
@@ -1,8 +0,0 @@
-
-
-
-
-
\ No newline at end of file
diff --git a/course/src/main/res/anim/course_slide_out_down.xml b/course/src/main/res/anim/course_slide_out_down.xml
deleted file mode 100644
index 5a97ec71e..000000000
--- a/course/src/main/res/anim/course_slide_out_down.xml
+++ /dev/null
@@ -1,8 +0,0 @@
-
-
-
-
\ No newline at end of file
diff --git a/course/src/main/res/anim/course_slide_out_up.xml b/course/src/main/res/anim/course_slide_out_up.xml
deleted file mode 100644
index 2db3f4d94..000000000
--- a/course/src/main/res/anim/course_slide_out_up.xml
+++ /dev/null
@@ -1,7 +0,0 @@
-
-
-
-
\ No newline at end of file
diff --git a/course/src/main/res/drawable/ic_course_arrow_down.xml b/course/src/main/res/drawable/course_ic_arrow_down.xml
similarity index 86%
rename from course/src/main/res/drawable/ic_course_arrow_down.xml
rename to course/src/main/res/drawable/course_ic_arrow_down.xml
index 8265e6957..0f502bde0 100644
--- a/course/src/main/res/drawable/ic_course_arrow_down.xml
+++ b/course/src/main/res/drawable/course_ic_arrow_down.xml
@@ -5,5 +5,5 @@
android:viewportHeight="24">
+ android:fillColor="#ffffff" />
diff --git a/course/src/main/res/drawable/course_ic_block.xml b/course/src/main/res/drawable/course_ic_block.xml
new file mode 100644
index 000000000..0e8cf67d6
--- /dev/null
+++ b/course/src/main/res/drawable/course_ic_block.xml
@@ -0,0 +1,51 @@
+
+
+
+
+
+
+
+
+
+
+
diff --git a/course/src/main/res/drawable/course_ic_calenday_sync.xml b/course/src/main/res/drawable/course_ic_calenday_sync.xml
deleted file mode 100644
index 32a1bf361..000000000
--- a/course/src/main/res/drawable/course_ic_calenday_sync.xml
+++ /dev/null
@@ -1,12 +0,0 @@
-
-
-
-
diff --git a/course/src/main/res/drawable/course_ic_certificate.xml b/course/src/main/res/drawable/course_ic_certificate.xml
new file mode 100644
index 000000000..a2a3ae2f9
--- /dev/null
+++ b/course/src/main/res/drawable/course_ic_certificate.xml
@@ -0,0 +1,9 @@
+
+
+
diff --git a/course/src/main/res/drawable/course_ic_discussion.xml b/course/src/main/res/drawable/course_ic_discussion.xml
new file mode 100644
index 000000000..0cd49c885
--- /dev/null
+++ b/course/src/main/res/drawable/course_ic_discussion.xml
@@ -0,0 +1,16 @@
+
+
+
+
+
+
diff --git a/course/src/main/res/drawable/ic_course_gated.xml b/course/src/main/res/drawable/course_ic_gated.xml
similarity index 89%
rename from course/src/main/res/drawable/ic_course_gated.xml
rename to course/src/main/res/drawable/course_ic_gated.xml
index bda1fb76a..4a0e638c6 100644
--- a/course/src/main/res/drawable/ic_course_gated.xml
+++ b/course/src/main/res/drawable/course_ic_gated.xml
@@ -5,8 +5,8 @@
android:viewportHeight="16">
+ android:fillColor="#D23228" />
+ android:fillColor="#ffffff" />
diff --git a/course/src/main/res/drawable/course_ic_pen.xml b/course/src/main/res/drawable/course_ic_pen.xml
new file mode 100644
index 000000000..b384fe453
--- /dev/null
+++ b/course/src/main/res/drawable/course_ic_pen.xml
@@ -0,0 +1,30 @@
+
+
+
+
+
+
+
+
diff --git a/course/src/main/res/drawable/course_ic_screen_rotation.xml b/course/src/main/res/drawable/course_ic_screen_rotation.xml
deleted file mode 100644
index 550684665..000000000
--- a/course/src/main/res/drawable/course_ic_screen_rotation.xml
+++ /dev/null
@@ -1,36 +0,0 @@
-
-
-
-
-
-
-
-
-
diff --git a/course/src/main/res/drawable/course_ic_video.xml b/course/src/main/res/drawable/course_ic_video.xml
new file mode 100644
index 000000000..a5cbd960b
--- /dev/null
+++ b/course/src/main/res/drawable/course_ic_video.xml
@@ -0,0 +1,23 @@
+
+
+
+
+
+
+
diff --git a/course/src/main/res/drawable/ic_calendar_month.xml b/course/src/main/res/drawable/ic_calendar_month.xml
deleted file mode 100644
index 434cf9907..000000000
--- a/course/src/main/res/drawable/ic_calendar_month.xml
+++ /dev/null
@@ -1,5 +0,0 @@
-
-
-
diff --git a/course/src/main/res/drawable/ic_course_block.xml b/course/src/main/res/drawable/ic_course_block.xml
deleted file mode 100644
index 9445f48f0..000000000
--- a/course/src/main/res/drawable/ic_course_block.xml
+++ /dev/null
@@ -1,52 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
diff --git a/course/src/main/res/drawable/ic_course_certificate.xml b/course/src/main/res/drawable/ic_course_certificate.xml
deleted file mode 100644
index 53ca91779..000000000
--- a/course/src/main/res/drawable/ic_course_certificate.xml
+++ /dev/null
@@ -1,9 +0,0 @@
-
-
-
diff --git a/course/src/main/res/drawable/ic_course_discussion.xml b/course/src/main/res/drawable/ic_course_discussion.xml
deleted file mode 100644
index b25683821..000000000
--- a/course/src/main/res/drawable/ic_course_discussion.xml
+++ /dev/null
@@ -1,17 +0,0 @@
-
-
-
-
-
-
diff --git a/course/src/main/res/drawable/ic_course_navigation_discussions.xml b/course/src/main/res/drawable/ic_course_navigation_discussions.xml
deleted file mode 100644
index 3f875475e..000000000
--- a/course/src/main/res/drawable/ic_course_navigation_discussions.xml
+++ /dev/null
@@ -1,17 +0,0 @@
-
-
-
-
-
-
diff --git a/course/src/main/res/drawable/ic_course_navigation_more.xml b/course/src/main/res/drawable/ic_course_navigation_more.xml
deleted file mode 100644
index acc5a2c56..000000000
--- a/course/src/main/res/drawable/ic_course_navigation_more.xml
+++ /dev/null
@@ -1,45 +0,0 @@
-
-
-
-
-
-
-
-
-
-
diff --git a/course/src/main/res/drawable/ic_course_navigation_outline.xml b/course/src/main/res/drawable/ic_course_navigation_outline.xml
deleted file mode 100644
index 984fc91ac..000000000
--- a/course/src/main/res/drawable/ic_course_navigation_outline.xml
+++ /dev/null
@@ -1,45 +0,0 @@
-
-
-
-
-
-
-
-
-
-
diff --git a/course/src/main/res/drawable/ic_course_navigation_video.xml b/course/src/main/res/drawable/ic_course_navigation_video.xml
deleted file mode 100644
index 095992bf5..000000000
--- a/course/src/main/res/drawable/ic_course_navigation_video.xml
+++ /dev/null
@@ -1,24 +0,0 @@
-
-
-
-
-
-
-
diff --git a/course/src/main/res/drawable/ic_course_pen.xml b/course/src/main/res/drawable/ic_course_pen.xml
deleted file mode 100644
index bf05828b6..000000000
--- a/course/src/main/res/drawable/ic_course_pen.xml
+++ /dev/null
@@ -1,31 +0,0 @@
-
-
-
-
-
-
-
-
diff --git a/course/src/main/res/drawable/ic_course_video.xml b/course/src/main/res/drawable/ic_course_video.xml
deleted file mode 100644
index 544550562..000000000
--- a/course/src/main/res/drawable/ic_course_video.xml
+++ /dev/null
@@ -1,24 +0,0 @@
-
-
-
-
-
-
-
diff --git a/course/src/main/res/drawable/ic_lock.xml b/course/src/main/res/drawable/ic_lock.xml
deleted file mode 100644
index 68cb9c1f5..000000000
--- a/course/src/main/res/drawable/ic_lock.xml
+++ /dev/null
@@ -1,9 +0,0 @@
-
-
-
diff --git a/course/src/main/res/drawable/rounded_top.xml b/course/src/main/res/drawable/rounded_top.xml
deleted file mode 100644
index fb871fb60..000000000
--- a/course/src/main/res/drawable/rounded_top.xml
+++ /dev/null
@@ -1,8 +0,0 @@
-
-
-
-
-
-
-
\ No newline at end of file
diff --git a/course/src/main/res/values/strings.xml b/course/src/main/res/values/strings.xml
index e4ae9e39d..f8fa29850 100644
--- a/course/src/main/res/values/strings.xml
+++ b/course/src/main/res/values/strings.xml
@@ -1,20 +1,10 @@
- Course Outline
- Course content
- Course units
- Course subsections
- Videos
Congratulations, you have earned this course certificate in \"%s\".
View certificate
Prev
- Previous Unit
Next
- Next Unit
Finish
- Last unit:
- Resume
- Discussion
Handouts
Announcements
Find important course information
@@ -24,7 +14,6 @@
Back to outline
Next section
This course hasn’t started yet.
- You are not connected to the Internet. Please check your Internet connection.
You can download content only from Wi-fi
This interactive component isn’t yet available
Explore other parts of this course or view this on web.
@@ -38,7 +27,6 @@
Dates Shifted
- Course dates are not currently available.
Home
Videos
@@ -63,7 +51,6 @@
Back
Your free audit access to this course expired on %s.
- Find a new course
This course will begin on %s. Come back then to start learning!
An error occurred while loading your course
diff --git a/course/src/main/res/values/values.xml b/course/src/main/res/values/values.xml
index 917f92663..a6b3daec9 100644
--- a/course/src/main/res/values/values.xml
+++ b/course/src/main/res/values/values.xml
@@ -1,7 +1,2 @@
-
-
-
\ 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
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