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 6d30a9044..292360b4d 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 @@ -5,6 +5,7 @@ import org.openedx.core.data.model.BlocksCompletionBody import org.openedx.core.data.model.CourseComponentStatus 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.CourseStructureModel import org.openedx.core.data.model.HandoutsModel @@ -76,4 +77,9 @@ interface CourseApi { @Query("status") status: String? = null, @Query("requested_fields") fields: List = emptyList() ): CourseEnrollments + + @GET("/api/mobile/v1/course_info/{course_id}/enrollment_details") + suspend fun getEnrollmentDetails( + @Path("course_id") courseId: String, + ): CourseEnrollmentDetails } diff --git a/core/src/main/java/org/openedx/core/data/model/CourseAccessDetails.kt b/core/src/main/java/org/openedx/core/data/model/CourseAccessDetails.kt new file mode 100644 index 000000000..592c366c9 --- /dev/null +++ b/core/src/main/java/org/openedx/core/data/model/CourseAccessDetails.kt @@ -0,0 +1,26 @@ +package org.openedx.core.data.model + +import com.google.gson.annotations.SerializedName +import org.openedx.core.domain.model.CourseAccessDetails +import org.openedx.core.utils.TimeUtils + +data class CourseAccessDetails( + @SerializedName("has_unmet_prerequisites") + val hasUnmetPrerequisites: Boolean, + @SerializedName("is_too_early") + val isTooEarly: Boolean, + @SerializedName("is_staff") + val isStaff: Boolean, + @SerializedName("audit_access_expires") + val auditAccessExpires: String?, + @SerializedName("courseware_access") + val coursewareAccess: CoursewareAccess?, +) { + fun mapToDomain() = CourseAccessDetails( + hasUnmetPrerequisites = hasUnmetPrerequisites, + isTooEarly = isTooEarly, + isStaff = isStaff, + auditAccessExpires = TimeUtils.iso8601ToDate(auditAccessExpires ?: ""), + coursewareAccess = coursewareAccess?.mapToDomain(), + ) +} diff --git a/core/src/main/java/org/openedx/core/data/model/CourseEnrollmentDetails.kt b/core/src/main/java/org/openedx/core/data/model/CourseEnrollmentDetails.kt new file mode 100644 index 000000000..6cfd6f166 --- /dev/null +++ b/core/src/main/java/org/openedx/core/data/model/CourseEnrollmentDetails.kt @@ -0,0 +1,36 @@ +package org.openedx.core.data.model + +import com.google.gson.annotations.SerializedName +import org.openedx.core.domain.model.CourseEnrollmentDetails + +data class CourseEnrollmentDetails( + @SerializedName("id") + val id: String, + @SerializedName("course_updates") + val courseUpdates: String, + @SerializedName("course_handouts") + val courseHandouts: String, + @SerializedName("discussion_url") + val discussionUrl: String, + @SerializedName("course_access_details") + val courseAccessDetails: CourseAccessDetails, + @SerializedName("certificate") + val certificate: Certificate?, + @SerializedName("enrollment_details") + val enrollmentDetails: EnrollmentDetails, + @SerializedName("course_info_overview") + val courseInfoOverview: CourseInfoOverview, +) { + fun mapToDomain(): CourseEnrollmentDetails { + return CourseEnrollmentDetails( + id = id, + courseUpdates = courseUpdates, + courseHandouts = courseHandouts, + discussionUrl = discussionUrl, + courseAccessDetails = courseAccessDetails.mapToDomain(), + certificate = certificate?.mapToDomain(), + enrollmentDetails = enrollmentDetails.mapToDomain(), + courseInfoOverview = courseInfoOverview.mapToDomain(), + ) + } +} diff --git a/core/src/main/java/org/openedx/core/data/model/CourseInfoOverview.kt b/core/src/main/java/org/openedx/core/data/model/CourseInfoOverview.kt new file mode 100644 index 000000000..3720d9604 --- /dev/null +++ b/core/src/main/java/org/openedx/core/data/model/CourseInfoOverview.kt @@ -0,0 +1,47 @@ +package org.openedx.core.data.model + +import com.google.gson.annotations.SerializedName +import org.openedx.core.domain.model.CourseInfoOverview +import org.openedx.core.utils.TimeUtils + +data class CourseInfoOverview( + @SerializedName("name") + val name: String, + @SerializedName("number") + val number: String, + @SerializedName("org") + val org: String, + @SerializedName("start") + val start: String?, + @SerializedName("start_display") + val startDisplay: String, + @SerializedName("start_type") + val startType: String, + @SerializedName("end") + val end: String?, + @SerializedName("is_self_paced") + val isSelfPaced: Boolean, + @SerializedName("media") + var media: Media?, + @SerializedName("course_sharing_utm_parameters") + val courseSharingUtmParameters: CourseSharingUtmParameters, + @SerializedName("course_about") + val courseAbout: String, + @SerializedName("course_modes") + val courseModes: List, +) { + fun mapToDomain() = CourseInfoOverview( + name = name, + number = number, + org = org, + start = TimeUtils.iso8601ToDate(start ?: ""), + startDisplay = startDisplay, + startType = startType, + end = TimeUtils.iso8601ToDate(end ?: ""), + isSelfPaced = isSelfPaced, + media = media?.mapToDomain(), + courseSharingUtmParameters = courseSharingUtmParameters.mapToDomain(), + courseAbout = courseAbout, + courseModes = courseModes.map { it.mapToDomain() }, + ) +} diff --git a/core/src/main/java/org/openedx/core/data/model/CourseMode.kt b/core/src/main/java/org/openedx/core/data/model/CourseMode.kt new file mode 100644 index 000000000..391cf02bb --- /dev/null +++ b/core/src/main/java/org/openedx/core/data/model/CourseMode.kt @@ -0,0 +1,25 @@ +package org.openedx.core.data.model + +import com.google.gson.annotations.SerializedName +import org.openedx.core.domain.model.CourseMode as CourseMode + +data class CourseMode( + @SerializedName("slug") + val slug: String, + @SerializedName("sku") + val sku: String?, + @SerializedName("android_sku") + val androidSku: String?, + @SerializedName("ios_sku") + val iosSku: String?, + @SerializedName("min_price") + val minPrice: Int, +) { + fun mapToDomain() = CourseMode( + slug = slug, + sku = sku, + androidSku = androidSku, + iosSku = iosSku, + minPrice = minPrice, + ) +} diff --git a/core/src/main/java/org/openedx/core/data/model/EnrollmentDetails.kt b/core/src/main/java/org/openedx/core/data/model/EnrollmentDetails.kt new file mode 100644 index 000000000..6a727c93a --- /dev/null +++ b/core/src/main/java/org/openedx/core/data/model/EnrollmentDetails.kt @@ -0,0 +1,23 @@ +package org.openedx.core.data.model + +import com.google.gson.annotations.SerializedName +import org.openedx.core.domain.model.EnrollmentDetails +import org.openedx.core.utils.TimeUtils + +data class EnrollmentDetails( + @SerializedName("date") + val date: String?, + @SerializedName("mode") + val mode: String, + @SerializedName("is_active") + val isActive: Boolean, + @SerializedName("upgrade_deadline") + val upgradeDeadline: String?, +) { + fun mapToDomain() = EnrollmentDetails( + created = TimeUtils.iso8601ToDate(date ?: ""), + mode = mode, + isActive = isActive, + upgradeDeadline = TimeUtils.iso8601ToDate(upgradeDeadline ?: ""), + ) +} 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 new file mode 100644 index 000000000..7bb875c47 --- /dev/null +++ b/core/src/main/java/org/openedx/core/domain/model/CourseAccessDetails.kt @@ -0,0 +1,14 @@ +package org.openedx.core.domain.model + +import android.os.Parcelable +import kotlinx.parcelize.Parcelize +import java.util.Date + +@Parcelize +data class CourseAccessDetails( + val hasUnmetPrerequisites: Boolean, + val isTooEarly: Boolean, + val isStaff: Boolean, + val auditAccessExpires: Date?, + val coursewareAccess: CoursewareAccess? +) : Parcelable 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 new file mode 100644 index 000000000..b08475ad1 --- /dev/null +++ b/core/src/main/java/org/openedx/core/domain/model/CourseEnrollmentDetails.kt @@ -0,0 +1,30 @@ +package org.openedx.core.domain.model + +import android.os.Parcelable +import kotlinx.parcelize.Parcelize +import java.util.Date + +@Parcelize +data class CourseEnrollmentDetails( + val id: String, + val courseUpdates: String, + val courseHandouts: String, + val discussionUrl: String, + val courseAccessDetails: CourseAccessDetails, + val certificate: Certificate?, + val enrollmentDetails: EnrollmentDetails, + val courseInfoOverview: CourseInfoOverview, +) : Parcelable { + fun isUpgradable(): Boolean { + val start = courseInfoOverview.start ?: return false + val upgradeDeadline = enrollmentDetails.upgradeDeadline ?: return false + if (enrollmentDetails.mode != "audit") return false + + return start < Date() && getCourseMode() != null && upgradeDeadline > Date() + } + + fun getCourseMode(): CourseMode? { + return courseInfoOverview.courseModes + .firstOrNull { it.slug == "verified" && !it.androidSku.isNullOrEmpty() } + } +} 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 new file mode 100644 index 000000000..338fc3a2b --- /dev/null +++ b/core/src/main/java/org/openedx/core/domain/model/CourseInfoOverview.kt @@ -0,0 +1,21 @@ +package org.openedx.core.domain.model + +import android.os.Parcelable +import kotlinx.parcelize.Parcelize +import java.util.Date + +@Parcelize +data class CourseInfoOverview( + val name: String, + val number: String, + val org: String, + val start: Date?, + val startDisplay: String, + val startType: String, + val end: Date?, + val isSelfPaced: Boolean, + var media: Media?, + val courseSharingUtmParameters: CourseSharingUtmParameters, + val courseAbout: String, + val courseModes: List, +) : Parcelable diff --git a/core/src/main/java/org/openedx/core/domain/model/CourseMode.kt b/core/src/main/java/org/openedx/core/domain/model/CourseMode.kt new file mode 100644 index 000000000..ff9bfdc70 --- /dev/null +++ b/core/src/main/java/org/openedx/core/domain/model/CourseMode.kt @@ -0,0 +1,13 @@ +package org.openedx.core.domain.model + +import android.os.Parcelable +import kotlinx.parcelize.Parcelize + +@Parcelize +data class CourseMode( + val slug: String?, + val sku: String?, + val androidSku: String?, + val iosSku: String?, + val minPrice: Int, +) : Parcelable 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 new file mode 100644 index 000000000..9ed74fa08 --- /dev/null +++ b/core/src/main/java/org/openedx/core/domain/model/EnrollmentDetails.kt @@ -0,0 +1,13 @@ +package org.openedx.core.domain.model + +import android.os.Parcelable +import kotlinx.parcelize.Parcelize +import java.util.Date + +@Parcelize +data class EnrollmentDetails( + val created: Date?, + val mode: String, + val isActive: Boolean, + val upgradeDeadline: Date? +) : Parcelable 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 5327b8cf5..7b799dce3 100644 --- a/core/src/main/java/org/openedx/core/utils/TimeUtils.kt +++ b/core/src/main/java/org/openedx/core/utils/TimeUtils.kt @@ -282,6 +282,11 @@ object TimeUtils { } } + fun getCourseAccessFormattedDate(context: Context, date: Date): String { + val resourceManager = ResourceManager(context) + return dateToCourseDate(resourceManager, date) + } + /** * Returns the number of days difference between the given date and the current date. */ 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 c32397a48..e4faa480e 100644 --- a/course/src/main/java/org/openedx/course/data/repository/CourseRepository.kt +++ b/course/src/main/java/org/openedx/course/data/repository/CourseRepository.kt @@ -6,6 +6,7 @@ import org.openedx.core.data.api.CourseApi import org.openedx.core.data.model.BlocksCompletionBody import org.openedx.core.data.storage.CorePreferences import org.openedx.core.domain.model.CourseComponentStatus +import org.openedx.core.domain.model.CourseEnrollmentDetails import org.openedx.core.domain.model.CourseStructure import org.openedx.core.exception.NoCachedDataException import org.openedx.core.module.db.DownloadDao @@ -58,6 +59,10 @@ class CourseRepository( return courseStructure[courseId]!! } + suspend fun getEnrollmentDetails(courseId: String): CourseEnrollmentDetails { + return api.getEnrollmentDetails(courseId = courseId).mapToDomain() + } + suspend fun getCourseStatus(courseId: String): CourseComponentStatus { val username = preferencesManager.user?.username ?: "" return api.getCourseStatus(username, courseId).mapToDomain() diff --git a/course/src/main/java/org/openedx/course/domain/interactor/CourseInteractor.kt b/course/src/main/java/org/openedx/course/domain/interactor/CourseInteractor.kt index 5bc859120..f9d7d3fc0 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 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 @@ -16,6 +17,10 @@ class CourseInteractor( return repository.getCourseStructure(courseId, isNeedRefresh) } + suspend fun getEnrollmentDetails(courseId: String): CourseEnrollmentDetails { + return repository.getEnrollmentDetails(courseId = courseId) + } + suspend fun getCourseStructureForVideos( courseId: String, isNeedRefresh: Boolean = false @@ -71,5 +76,4 @@ class CourseInteractor( suspend fun removeDownloadModel(id: String) = repository.removeDownloadModel(id) fun getDownloadModels() = repository.getDownloadModels() - } 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 3b59be61d..4182ca2c1 100644 --- a/course/src/main/java/org/openedx/course/presentation/CourseRouter.kt +++ b/course/src/main/java/org/openedx/course/presentation/CourseRouter.kt @@ -62,4 +62,6 @@ interface CourseRouter { fun navigateToDownloadQueue(fm: FragmentManager, descendants: List = arrayListOf()) fun navigateToVideoQuality(fm: FragmentManager, videoQualityType: VideoQualityType) + + fun navigateToMain(fm: FragmentManager, courseId: String?, infoType: String?, openTab: String) } diff --git a/course/src/main/java/org/openedx/course/presentation/container/CollapsingLayout.kt b/course/src/main/java/org/openedx/course/presentation/container/CollapsingLayout.kt index 08f6cf96a..57445dc74 100644 --- a/course/src/main/java/org/openedx/course/presentation/container/CollapsingLayout.kt +++ b/course/src/main/java/org/openedx/course/presentation/container/CollapsingLayout.kt @@ -79,6 +79,7 @@ internal fun CollapsingLayout( modifier: Modifier = Modifier, courseImage: Bitmap, imageHeight: Int, + isEnabled: Boolean, expandedTop: @Composable BoxScope.() -> Unit, collapsedTop: @Composable BoxScope.() -> Unit, navigation: @Composable BoxScope.() -> Unit, @@ -166,10 +167,15 @@ internal fun CollapsingLayout( } } + val collapsingModifier = if (isEnabled) { + modifier + .nestedScroll(nestedScrollConnection) + } else { + modifier + } Box( - modifier = modifier + modifier = collapsingModifier .fillMaxSize() - .nestedScroll(nestedScrollConnection) .pointerInput(Unit) { var yStart = 0f coroutineScope { @@ -221,6 +227,7 @@ internal fun CollapsingLayout( backBtnStartPadding = backBtnStartPadding, courseImage = courseImage, imageHeight = imageHeight, + isEnabled = isEnabled, onBackClick = onBackClick, expandedTop = expandedTop, navigation = navigation, @@ -244,6 +251,7 @@ internal fun CollapsingLayout( courseImage = courseImage, imageHeight = imageHeight, toolbarBackgroundOffset = toolbarBackgroundOffset, + isEnabled = isEnabled, onBackClick = onBackClick, expandedTop = expandedTop, collapsedTop = collapsedTop, @@ -265,6 +273,7 @@ private fun CollapsingLayoutTablet( backBtnStartPadding: Dp, courseImage: Bitmap, imageHeight: Int, + isEnabled: Boolean, onBackClick: () -> Unit, expandedTop: @Composable BoxScope.() -> Unit, navigation: @Composable BoxScope.() -> Unit, @@ -408,15 +417,22 @@ private fun CollapsingLayoutTablet( content = navigation, ) - Box( - modifier = Modifier + val bodyPadding = expandedTopHeight.value + backgroundImageHeight.value + navigationHeight.value + val bodyModifier = if (isEnabled) { + Modifier .offset { IntOffset( x = 0, - y = (expandedTopHeight.value + backgroundImageHeight.value + navigationHeight.value).roundToInt() + y = bodyPadding.roundToInt() ) } - .padding(bottom = with(localDensity) { (expandedTopHeight.value + navigationHeight.value + backgroundImageHeight.value).toDp() }), + .padding(bottom = with(localDensity) { bodyPadding.toDp() }) + } else { + Modifier + .padding(top = with(localDensity) { if (bodyPadding < 0) 0.toDp() else bodyPadding.toDp() }) + } + Box( + modifier = bodyModifier, content = bodyContent, ) } @@ -439,6 +455,7 @@ private fun CollapsingLayoutMobile( courseImage: Bitmap, imageHeight: Int, toolbarBackgroundOffset: Int, + isEnabled: Boolean, onBackClick: () -> Unit, expandedTop: @Composable BoxScope.() -> Unit, collapsedTop: @Composable BoxScope.() -> Unit, @@ -712,15 +729,23 @@ private fun CollapsingLayoutMobile( content = navigation, ) - Box( - modifier = Modifier + val bodyPadding = + expandedTopHeight.value + offset.value + backgroundImageHeight.value + navigationHeight.value - blurImagePaddingPx * factor + val bodyModifier = if (isEnabled) { + Modifier .offset { IntOffset( x = 0, - y = (expandedTopHeight.value + offset.value + backgroundImageHeight.value + navigationHeight.value - blurImagePaddingPx * factor).roundToInt() + y = bodyPadding.roundToInt() ) } - .padding(bottom = with(localDensity) { (collapsedTopHeight.value + navigationHeight.value).toDp() }), + .padding(bottom = with(localDensity) { (collapsedTopHeight.value + navigationHeight.value).toDp() }) + } else { + Modifier + .padding(top = with(localDensity) { if (bodyPadding < 0) 0.toDp() else bodyPadding.toDp() }) + } + Box( + modifier = bodyModifier, content = bodyContent, ) } @@ -764,6 +789,7 @@ private fun CollapsingLayoutPreview() { pagerState = rememberPagerState(pageCount = { CourseContainerTab.entries.size }) ) }, + isEnabled = true, onBackClick = {}, bodyContent = {} ) diff --git a/course/src/main/java/org/openedx/course/presentation/container/CourseAccessStatus.kt b/course/src/main/java/org/openedx/course/presentation/container/CourseAccessStatus.kt new file mode 100644 index 000000000..a841bb01b --- /dev/null +++ b/course/src/main/java/org/openedx/course/presentation/container/CourseAccessStatus.kt @@ -0,0 +1,14 @@ +package org.openedx.course.presentation.container + +import java.util.Date + +data class CourseAccessStatus( + val accessError: CourseAccessError? = null, + val date: Date? = null, + val sku: String? = null +) + +enum class CourseAccessError { + COURSE_EXPIRED_NOT_UPGRADABLE, COURSE_EXPIRED_UPGRADABLE, COURSE_NOT_STARTED, COURSE_NO_ACCESS +} + 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 49d6b8cae..73de0b475 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 @@ -1,11 +1,18 @@ package org.openedx.course.presentation.container +import android.content.res.Configuration import android.os.Bundle import android.view.View import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.Image +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.ColumnScope 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 @@ -16,6 +23,8 @@ import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.pager.HorizontalPager import androidx.compose.foundation.pager.PagerState import androidx.compose.foundation.pager.rememberPagerState +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll import androidx.compose.material.ExperimentalMaterialApi import androidx.compose.material.MaterialTheme import androidx.compose.material.Scaffold @@ -23,6 +32,7 @@ import androidx.compose.material.SnackbarData import androidx.compose.material.SnackbarDuration import androidx.compose.material.SnackbarHost import androidx.compose.material.SnackbarHostState +import androidx.compose.material.Text import androidx.compose.material.pullrefresh.PullRefreshIndicator import androidx.compose.material.pullrefresh.pullRefresh import androidx.compose.material.pullrefresh.rememberPullRefreshState @@ -39,8 +49,15 @@ import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.painter.Painter +import androidx.compose.ui.platform.LocalConfiguration +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.ViewCompositionStrategy +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.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.core.os.bundleOf import androidx.core.view.isVisible @@ -50,23 +67,32 @@ import androidx.lifecycle.lifecycleScope import com.google.android.material.snackbar.Snackbar import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.launch +import org.koin.android.ext.android.inject import org.koin.androidx.compose.koinViewModel import org.koin.androidx.viewmodel.ext.android.viewModel import org.koin.core.parameter.parametersOf +import org.openedx.core.extension.tagId import org.openedx.core.extension.takeIfNotEmpty import org.openedx.core.presentation.global.viewBinding import org.openedx.core.presentation.settings.calendarsync.CalendarSyncDialog import org.openedx.core.presentation.settings.calendarsync.CalendarSyncDialogType import org.openedx.core.ui.HandleUIMessage import org.openedx.core.ui.OfflineModeDialog +import org.openedx.core.ui.OpenEdXButton +import org.openedx.core.ui.OpenEdXOutlinedButton import org.openedx.core.ui.RoundTabsBar import org.openedx.core.ui.WindowSize +import org.openedx.core.ui.displayCutoutForLandscape import org.openedx.core.ui.rememberWindowSize +import org.openedx.core.ui.statusBarsInset import org.openedx.core.ui.theme.OpenEdXTheme import org.openedx.core.ui.theme.appColors +import org.openedx.core.ui.theme.appTypography +import org.openedx.core.utils.TimeUtils import org.openedx.course.DatesShiftedSnackBar import org.openedx.course.R import org.openedx.course.databinding.FragmentCourseContainerBinding +import org.openedx.course.presentation.CourseRouter import org.openedx.course.presentation.dates.CourseDatesScreen import org.openedx.course.presentation.handouts.HandoutsScreen import org.openedx.course.presentation.handouts.HandoutsType @@ -74,6 +100,7 @@ import org.openedx.course.presentation.outline.CourseOutlineScreen import org.openedx.course.presentation.ui.CourseVideosScreen import org.openedx.course.presentation.ui.DatesShiftedSnackBar import org.openedx.discussion.presentation.topics.DiscussionTopicsScreen +import java.util.Date class CourseContainerFragment : Fragment(R.layout.fragment_course_container) { @@ -87,6 +114,7 @@ class CourseContainerFragment : Fragment(R.layout.fragment_course_container) { requireArguments().getString(ARG_RESUME_BLOCK, "") ) } + private val courseRouter by inject() private val permissionLauncher = registerForActivityResult( ActivityResultContracts.RequestMultiplePermissions() @@ -124,8 +152,8 @@ class CourseContainerFragment : Fragment(R.layout.fragment_course_container) { } private fun observe() { - viewModel.dataReady.observe(viewLifecycleOwner) { isReady -> - if (isReady == false) { + viewModel.accessStatus.observe(viewLifecycleOwner) { accessStatus -> + if (accessStatus?.accessError == CourseAccessError.COURSE_NO_ACCESS) { viewModel.courseRouter.navigateToNoAccess( requireActivity().supportFragmentManager, viewModel.courseName @@ -154,14 +182,23 @@ class CourseContainerFragment : Fragment(R.layout.fragment_course_container) { private fun initCourseView() { binding.composeCollapsingLayout.setContent { val isNavigationEnabled by viewModel.isNavigationEnabled.collectAsState() + val fm = requireActivity().supportFragmentManager CourseDashboard( viewModel = viewModel, isNavigationEnabled = isNavigationEnabled, isResumed = isResumed, - fragmentManager = requireActivity().supportFragmentManager, + fragmentManager = fm, bundle = requireArguments(), onRefresh = { page -> onRefresh(page) + }, + findNewCourseClick = { + courseRouter.navigateToMain( + fm = fm, + courseId = null, + infoType = null, + openTab = "DISCOVER" + ) } ) } @@ -287,11 +324,12 @@ class CourseContainerFragment : Fragment(R.layout.fragment_course_container) { @Composable fun CourseDashboard( viewModel: CourseContainerViewModel, - onRefresh: (page: Int) -> Unit, isNavigationEnabled: Boolean, isResumed: Boolean, fragmentManager: FragmentManager, - bundle: Bundle + bundle: Bundle, + onRefresh: (page: Int) -> Unit, + findNewCourseClick: () -> Unit ) { OpenEdXTheme { val windowSize = rememberWindowSize() @@ -307,7 +345,8 @@ fun CourseDashboard( val refreshing by viewModel.refreshing.collectAsState(true) val courseImage by viewModel.courseImage.collectAsState() val uiMessage by viewModel.uiMessage.collectAsState(null) - val openTab = bundle.getString(CourseContainerFragment.ARG_OPEN_TAB, CourseContainerTab.HOME.name) + val openTab = + bundle.getString(CourseContainerFragment.ARG_OPEN_TAB, CourseContainerTab.HOME.name) val requiredTab = when (openTab.uppercase()) { CourseContainerTab.HOME.name -> CourseContainerTab.HOME CourseContainerTab.VIDEOS.name -> CourseContainerTab.VIDEOS @@ -321,7 +360,7 @@ fun CourseDashboard( initialPage = CourseContainerTab.entries.indexOf(requiredTab), pageCount = { CourseContainerTab.entries.size } ) - val dataReady = viewModel.dataReady.observeAsState() + val accessStatus = viewModel.accessStatus.observeAsState() val tabState = rememberLazyListState() val snackState = remember { SnackbarHostState() } val pullRefreshState = rememberPullRefreshState( @@ -343,93 +382,156 @@ fun CourseDashboard( tabState.animateScrollToItem(pagerState.currentPage) } - Box { - CollapsingLayout( - modifier = Modifier - .fillMaxWidth() - .padding(paddingValues) - .pullRefresh(pullRefreshState), - courseImage = courseImage, - imageHeight = 200, - expandedTop = { - ExpandedHeaderContent( - courseTitle = viewModel.courseName, - org = viewModel.organization - ) - }, - collapsedTop = { - CollapsedHeaderContent( - courseTitle = viewModel.courseName - ) - }, - navigation = { - if (isNavigationEnabled) { - RoundTabsBar( - items = CourseContainerTab.entries, - contentPadding = PaddingValues(horizontal = 12.dp, vertical = 16.dp), - rowState = tabState, - pagerState = pagerState, - withPager = true, - onTabClicked = viewModel::courseContainerTabClickedEvent + Column( + modifier = Modifier.fillMaxSize() + ) { + Box( + modifier = Modifier.weight(1f) + ) { + CollapsingLayout( + modifier = Modifier + .fillMaxWidth() + .padding(paddingValues) + .pullRefresh(pullRefreshState), + courseImage = courseImage, + imageHeight = 200, + expandedTop = { + ExpandedHeaderContent( + courseTitle = viewModel.courseName, + org = viewModel.organization ) - } else { - Spacer(modifier = Modifier.height(52.dp)) - } - }, - onBackClick = { - fragmentManager.popBackStack() - }, - bodyContent = { - if (dataReady.value == true) { - DashboardPager( - windowSize = windowSize, - viewModel = viewModel, - pagerState = pagerState, - isNavigationEnabled = isNavigationEnabled, - isResumed = isResumed, - fragmentManager = fragmentManager, - bundle = bundle + }, + collapsedTop = { + CollapsedHeaderContent( + courseTitle = viewModel.courseName ) + }, + navigation = { + if (isNavigationEnabled) { + RoundTabsBar( + items = CourseContainerTab.entries, + contentPadding = PaddingValues( + horizontal = 12.dp, + vertical = 16.dp + ), + rowState = tabState, + pagerState = pagerState, + withPager = true, + onTabClicked = viewModel::courseContainerTabClickedEvent + ) + } else { + accessStatus.value?.let { + if (it.accessError == null) { + Spacer(modifier = Modifier.height(52.dp)) + } + } + } + }, + isEnabled = accessStatus.value?.accessError == null, + onBackClick = { + fragmentManager.popBackStack() + }, + bodyContent = { + accessStatus.value?.let { accessStatus -> + when (accessStatus.accessError) { + CourseAccessError.COURSE_EXPIRED_NOT_UPGRADABLE -> { + CourseExpiredNotUpgradeableMessage( + date = accessStatus.date ?: Date() + ) + } + + CourseAccessError.COURSE_EXPIRED_UPGRADABLE -> { + CourseExpiredUpgradeableMessage( + date = accessStatus.date ?: Date() + ) + } + + CourseAccessError.COURSE_NOT_STARTED -> { + CourseNotStartedMessage( + date = accessStatus.date ?: Date() + ) + } + + CourseAccessError.COURSE_NO_ACCESS -> { + + } + + null -> { + DashboardPager( + windowSize = windowSize, + viewModel = viewModel, + pagerState = pagerState, + isNavigationEnabled = isNavigationEnabled, + isResumed = isResumed, + fragmentManager = fragmentManager, + bundle = bundle + ) + } + } + } } + ) + PullRefreshIndicator( + refreshing, + pullRefreshState, + 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 = true + onRefresh(pagerState.currentPage) + } + ) } - ) - PullRefreshIndicator( - refreshing, - pullRefreshState, - Modifier.align(Alignment.TopCenter) - ) - var isInternetConnectionShown by rememberSaveable { - mutableStateOf(false) + SnackbarHost( + modifier = Modifier.align(Alignment.BottomStart), + hostState = snackState + ) { snackbarData: SnackbarData -> + DatesShiftedSnackBar( + showAction = CourseContainerTab.entries[pagerState.currentPage] != CourseContainerTab.DATES, + onViewDates = { + scrollToDates(scope, pagerState) + }, + onClose = { + snackbarData.dismiss() + } + ) + } } - if (!isInternetConnectionShown && !viewModel.hasInternetConnection) { - OfflineModeDialog( - Modifier - .fillMaxWidth() - .align(Alignment.BottomCenter), - onDismissCLick = { - isInternetConnectionShown = true - }, - onReloadClick = { - isInternetConnectionShown = true - onRefresh(pagerState.currentPage) + + accessStatus.value?.let { accessStatus -> + when (accessStatus.accessError) { + CourseAccessError.COURSE_EXPIRED_NOT_UPGRADABLE -> { + CourseExpiredNotUpgradeableButtons(onBackClick = { fragmentManager.popBackStack() }) } - ) - } - SnackbarHost( - modifier = Modifier.align(Alignment.BottomStart), - hostState = snackState - ) { snackbarData: SnackbarData -> - DatesShiftedSnackBar( - showAction = CourseContainerTab.entries[pagerState.currentPage] != CourseContainerTab.DATES, - onViewDates = { - scrollToDates(scope, pagerState) - }, - onClose = { - snackbarData.dismiss() + CourseAccessError.COURSE_EXPIRED_UPGRADABLE -> { + CourseExpiredUpgradeableButtons( + sku = accessStatus.sku, + findNewCourseClick = findNewCourseClick + ) } - ) + + CourseAccessError.COURSE_NOT_STARTED -> { + CourseNotStartedButtons(onBackClick = { fragmentManager.popBackStack() }) + } + + CourseAccessError.COURSE_NO_ACCESS, null -> { + + } + } } } } @@ -438,7 +540,7 @@ fun CourseDashboard( @OptIn(ExperimentalFoundationApi::class) @Composable -fun DashboardPager( +private fun DashboardPager( windowSize: WindowSize, viewModel: CourseContainerViewModel, pagerState: PagerState, @@ -543,9 +645,249 @@ fun DashboardPager( } } +@Composable +private fun CourseExpiredNotUpgradeableMessage(date: Date) { + CourseErrorMessagePlaceholder( + iconPainter = painterResource(id = R.drawable.ic_course_update), + textContent = { + Text( + textAlign = TextAlign.Center, + text = stringResource( + R.string.course_error_expired_not_upgradeable_title, + TimeUtils.getCourseAccessFormattedDate(LocalContext.current, date) + ), + style = MaterialTheme.appTypography.bodyMedium, + color = MaterialTheme.appColors.textDark + ) + } + ) +} + +@Composable +private fun CourseExpiredNotUpgradeableButtons(onBackClick: () -> Unit) { + CourseErrorButtonsPlaceholder { + OpenEdXButton( + modifier = Modifier + .fillMaxWidth(), + text = stringResource(R.string.course_error_back), + onClick = { + onBackClick() + }, + ) + } +} + +@Composable +private fun CourseExpiredUpgradeableMessage(date: Date) { + CourseErrorMessagePlaceholder( + iconPainter = painterResource(id = R.drawable.ic_course_update), + textContent = { + Text( + textAlign = TextAlign.Center, + text = stringResource( + R.string.course_error_expired_upgradeable_title, + TimeUtils.getCourseAccessFormattedDate(LocalContext.current, date) + ), + style = MaterialTheme.appTypography.bodyMedium, + color = MaterialTheme.appColors.textDark + ) + Column( + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + Row( + horizontalArrangement = Arrangement.spacedBy(4.dp) + ) { + Image( + painter = painterResource(id = R.drawable.ic_course_check), + contentDescription = null + ) + Text( + textAlign = TextAlign.Left, + text = stringResource(R.string.course_error_expired_upgradeable_option_1), + style = MaterialTheme.appTypography.bodyMedium, + color = MaterialTheme.appColors.textDark + ) + } + Row( + horizontalArrangement = Arrangement.spacedBy(4.dp) + ) { + Image( + painter = painterResource(id = R.drawable.ic_course_check), + contentDescription = null + ) + Text( + textAlign = TextAlign.Left, + text = stringResource(R.string.course_error_expired_upgradeable_option_2), + style = MaterialTheme.appTypography.bodyMedium, + color = MaterialTheme.appColors.textDark + ) + } + Row( + horizontalArrangement = Arrangement.spacedBy(4.dp) + ) { + Image( + painter = painterResource(id = R.drawable.ic_course_check), + contentDescription = null + ) + Text( + textAlign = TextAlign.Left, + text = stringResource(R.string.course_error_expired_upgradeable_option_3), + style = MaterialTheme.appTypography.bodyMedium, + color = MaterialTheme.appColors.textDark + ) + } + } + } + ) +} + +@Composable +private fun CourseExpiredUpgradeableButtons( + sku: String?, + findNewCourseClick: () -> Unit +) { + CourseErrorButtonsPlaceholder { + OpenEdXOutlinedButton( + modifier = Modifier + .fillMaxWidth(), + text = stringResource(R.string.course_error_expired_upgradeable_find_new_course_button), + backgroundColor = MaterialTheme.appColors.background, + textColor = MaterialTheme.appColors.primary, + borderColor = MaterialTheme.appColors.primary, + onClick = { + findNewCourseClick() + } + ) + if (!sku.isNullOrEmpty()) { + OpenEdXButton( + modifier = Modifier + .fillMaxWidth(), + onClick = { + + }, + content = { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(4.dp) + ) { + Image( + painter = painterResource(id = R.drawable.ic_course_arrow_circle_up), + contentDescription = null + ) + val buttonText = + stringResource( + R.string.course_error_expired_upgradeable_upgrade_now_button, sku + ) + Text( + modifier = Modifier.testTag("txt_${buttonText.tagId()}"), + text = buttonText, + color = MaterialTheme.appColors.primaryButtonText, + style = MaterialTheme.appTypography.labelLarge + ) + } + } + ) + } + } +} + +@Composable +private fun CourseNotStartedMessage(date: Date) { + CourseErrorMessagePlaceholder( + iconPainter = painterResource(id = R.drawable.ic_course_dates), + textContent = { + Text( + textAlign = TextAlign.Center, + text = stringResource( + R.string.course_error_not_started_title, + TimeUtils.getCourseAccessFormattedDate(LocalContext.current, date) + ), + style = MaterialTheme.appTypography.bodyMedium, + color = MaterialTheme.appColors.textDark + ) + } + ) +} + +@Composable +private fun CourseNotStartedButtons(onBackClick: () -> Unit) { + CourseErrorButtonsPlaceholder { + OpenEdXButton( + modifier = Modifier + .fillMaxWidth(), + text = stringResource(R.string.course_error_back), + onClick = { + onBackClick() + }, + ) + } +} + +@Composable +private fun CourseErrorMessagePlaceholder( + iconPainter: Painter, + textContent: @Composable ColumnScope.() -> Unit +) { + Box( + modifier = Modifier + .fillMaxSize() + .statusBarsInset() + .displayCutoutForLandscape() + .background(MaterialTheme.appColors.background), + contentAlignment = Alignment.TopCenter + ) { + Column( + modifier = Modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()) + .padding(24.dp) + ) { + Row( + modifier = Modifier + .fillMaxSize() + ) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(24.dp) + ) { + if (LocalConfiguration.current.orientation == Configuration.ORIENTATION_PORTRAIT) { + Image( + painter = iconPainter, + contentDescription = null + ) + } + textContent() + } + } + } + } +} + +@Composable +private fun CourseErrorButtonsPlaceholder( + buttonContent: @Composable ColumnScope.() -> Unit +) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(start = 24.dp, top = 24.dp, end = 24.dp, bottom = 27.dp), + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + buttonContent() + } +} + @OptIn(ExperimentalFoundationApi::class) private fun scrollToDates(scope: CoroutineScope, pagerState: PagerState) { scope.launch { pagerState.animateScrollToPage(CourseContainerTab.entries.indexOf(CourseContainerTab.DATES)) } } + +@Preview +@Composable +private fun previewErrorScreen() { + OpenEdXTheme { + //CourseExpiredUpgradeable() + } +} 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 4e233e3d7..5b178609a 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 @@ -23,6 +23,7 @@ import org.openedx.core.SingleEventLiveData import org.openedx.core.UIMessage import org.openedx.core.config.Config import org.openedx.core.data.storage.CorePreferences +import org.openedx.core.domain.model.CourseEnrollmentDetails import org.openedx.core.exception.NoCachedDataException import org.openedx.core.extension.isInternetError import org.openedx.core.presentation.settings.calendarsync.CalendarSyncDialogType @@ -72,9 +73,9 @@ class CourseContainerViewModel( val courseRouter: CourseRouter, ) : BaseViewModel() { - private val _dataReady = MutableLiveData() - val dataReady: LiveData - get() = _dataReady + private val _accessStatus = MutableLiveData() + val accessStatus: LiveData + get() = _accessStatus private val _errorMessage = SingleEventLiveData() val errorMessage: LiveData @@ -162,30 +163,46 @@ class CourseContainerViewModel( fun preloadCourseStructure() { courseDashboardViewed() - if (_dataReady.value != null) { + if (_accessStatus.value != null) { return } _showProgress.value = true viewModelScope.launch { try { - val courseStructure = interactor.getCourseStructure(courseId, true) - courseName = courseStructure.name - _organization = courseStructure.org - _isSelfPaced = courseStructure.isSelfPaced - loadCourseImage(courseStructure.media?.image?.large) - _dataReady.value = courseStructure.start?.let { start -> + val enrollmentDetails = interactor.getEnrollmentDetails(courseId) + val accessStatus = getCourseAccessStatus(enrollmentDetails) + val courseInfoOverview = enrollmentDetails.courseInfoOverview + courseName = courseInfoOverview.name + _organization = courseInfoOverview.org + _isSelfPaced = courseInfoOverview.isSelfPaced + loadCourseImage(courseInfoOverview.media?.image?.large) + + if (accessStatus.accessError != null) { + _isNavigationEnabled.value = false + _showProgress.value = false + _accessStatus.value = accessStatus + return@launch + } + + interactor.getCourseStructure(courseId, true) + courseInfoOverview.start?.let { start -> val isReady = start < Date() if (isReady) { _isNavigationEnabled.value = true + _accessStatus.value = accessStatus + if (resumeBlockId.isNotEmpty()) { + delay(500L) + courseNotifier.send(CourseOpenBlock(resumeBlockId)) + } + } else { + _accessStatus.value = + CourseAccessStatus(accessError = CourseAccessError.COURSE_NO_ACCESS) } - isReady - } - if (_dataReady.value == true && resumeBlockId.isNotEmpty()) { - delay(500L) - courseNotifier.send(CourseOpenBlock(resumeBlockId)) } + } catch (e: Exception) { + e.printStackTrace() if (e.isInternetError() || e is NoCachedDataException) { _errorMessage.value = resourceManager.getString(CoreR.string.core_error_no_connection) @@ -197,6 +214,52 @@ class CourseContainerViewModel( } } + private fun getCourseAccessStatus(enrollmentDetails: CourseEnrollmentDetails): CourseAccessStatus { + enrollmentDetails.courseAccessDetails.let { accessDetails -> + if (accessDetails.coursewareAccess?.hasAccess != false) { + return CourseAccessStatus() + } + + if ((enrollmentDetails.courseInfoOverview.end ?: Date()) < Date()) { + if (enrollmentDetails.isUpgradable()) { + return CourseAccessStatus( + accessError = CourseAccessError.COURSE_EXPIRED_UPGRADABLE, + date = enrollmentDetails.courseInfoOverview.end, + sku = enrollmentDetails.getCourseMode()?.androidSku + ) + } else { + return CourseAccessStatus( + accessError = CourseAccessError.COURSE_EXPIRED_NOT_UPGRADABLE, + date = enrollmentDetails.courseInfoOverview.end + ) + } + } else { + val errorCode = accessDetails.coursewareAccess?.errorCode + ?: return CourseAccessStatus() + return when (errorCode) { + "notStarted" -> { + CourseAccessStatus( + accessError = CourseAccessError.COURSE_NOT_STARTED, + date = enrollmentDetails.courseInfoOverview.start + ) + } + + "auditExpired" -> { + CourseAccessStatus( + accessError = CourseAccessError.COURSE_EXPIRED_UPGRADABLE, + date = enrollmentDetails.courseAccessDetails.auditAccessExpires, + sku = enrollmentDetails.getCourseMode()?.androidSku + ) + } + + else -> { + CourseAccessStatus() + } + } + } + } + } + private fun loadCourseImage(imageUrl: String?) { imageProcessor.loadImage( imageUrl = config.getApiHostURL() + imageUrl, diff --git a/course/src/main/res/drawable/ic_course_arrow_circle_up.xml b/course/src/main/res/drawable/ic_course_arrow_circle_up.xml new file mode 100644 index 000000000..b2fab59ac --- /dev/null +++ b/course/src/main/res/drawable/ic_course_arrow_circle_up.xml @@ -0,0 +1,9 @@ + + + diff --git a/course/src/main/res/drawable/ic_course_dates.xml b/course/src/main/res/drawable/ic_course_dates.xml new file mode 100644 index 000000000..d0674de0f --- /dev/null +++ b/course/src/main/res/drawable/ic_course_dates.xml @@ -0,0 +1,9 @@ + + + diff --git a/course/src/main/res/drawable/ic_course_update.xml b/course/src/main/res/drawable/ic_course_update.xml new file mode 100644 index 000000000..062c76c28 --- /dev/null +++ b/course/src/main/res/drawable/ic_course_update.xml @@ -0,0 +1,32 @@ + + + + + + diff --git a/course/src/main/res/values/strings.xml b/course/src/main/res/values/strings.xml index 2fcb1d950..ea7ab3b18 100644 --- a/course/src/main/res/values/strings.xml +++ b/course/src/main/res/values/strings.xml @@ -73,4 +73,13 @@ %1$s of %2$s assignments complete + Back + Your free audit access to this course expired on %s. + Your free audit access to this course expired on %s. Please upgrade to continue learning and receive a verified certificate. + Earn a verified certificate of completion to showcase on your resumé + Unlock access to all course activities, including graded assignments + Full access to course content and material, even after the course ends + Find a new course + Upgrade now for %s + This course will begin on %s. Come back then to start learning!